AI tracker: owner-only Setup tab (per-provider connections, skills, instructions, plugins, routines, agents, plan/key labels, notes; ai.provider_setup_providers + provider_setup_entries, secrets refused)
Some checks failed
Deploy production / deploy (push) Failing after 1m3s
Some checks failed
Deploy production / deploy (push) Failing after 1m3s
This commit is contained in:
51
drizzle/20261026090557_ai_provider_setup/migration.sql
Normal file
51
drizzle/20261026090557_ai_provider_setup/migration.sql
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
CREATE TABLE "ai"."provider_setup_entries" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"provider_id" integer NOT NULL,
|
||||||
|
"section" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"note" text,
|
||||||
|
"scope" text,
|
||||||
|
"link" text,
|
||||||
|
"body" text,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "provider_setup_entries_section_check" CHECK ("section" IN ('connections','skills','instructions','plugins','routines','agents','plan','notes')),
|
||||||
|
CONSTRAINT "provider_setup_entries_scope_check" CHECK ("scope" IS NULL OR "scope" IN ('global','per-repo','app')),
|
||||||
|
CONSTRAINT "provider_setup_entries_name_check" CHECK (length(btrim("name")) > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "ai"."provider_setup_providers" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"products" text,
|
||||||
|
"color" text DEFAULT 'zinc' NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "provider_setup_providers_name_check" CHECK (length(btrim("name")) > 0),
|
||||||
|
CONSTRAINT "provider_setup_providers_color_check" CHECK ("color" IN ('orange','green','blue','violet','pink','sky','amber','zinc'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "provider_setup_entries_provider_idx" ON "ai"."provider_setup_entries" ("provider_id","section","sort_order");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "provider_setup_providers_name_key" ON "ai"."provider_setup_providers" ("name");--> statement-breakpoint
|
||||||
|
ALTER TABLE "ai"."provider_setup_entries" ADD CONSTRAINT "provider_setup_entries_JxI1C1Zk458j_fkey" FOREIGN KEY ("provider_id") REFERENCES "ai"."provider_setup_providers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Seed the provider cards (owner edits them on /trackers/ai/setup). Entries are added there.
|
||||||
|
INSERT INTO "ai"."provider_setup_providers" ("name", "products", "color", "sort_order") VALUES
|
||||||
|
('Claude', 'claude.ai + Claude Code', 'orange', 10),
|
||||||
|
('ChatGPT', 'ChatGPT + Codex', 'green', 20),
|
||||||
|
('Gemini', 'Gemini + Gemini CLI', 'blue', 30),
|
||||||
|
('Grok', 'Grok + Grok Bot', 'violet', 40),
|
||||||
|
('Copilot', 'GitHub Copilot', 'sky', 50)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Same privacy as the rest of schema ai: app role only (default privileges already cover new
|
||||||
|
-- tables; granted explicitly too). Grants only run on prod (migrations as `personal`).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON "ai"."provider_setup_providers", "ai"."provider_setup_entries" TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ai TO personal_app;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
42426
drizzle/20261026090557_ai_provider_setup/snapshot.json
Normal file
42426
drizzle/20261026090557_ai_provider_setup/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
217
src/lib/server/ai-setup.ts
Normal file
217
src/lib/server/ai-setup.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
// AI tracker, Setup tab (owner-only): how each AI provider is set up. Cards are providers,
|
||||||
|
// each with entries grouped by section. Labels only: values that look like secrets are refused.
|
||||||
|
import { db } from '#lib/server/db/index.js';
|
||||||
|
import {
|
||||||
|
AI_SETUP_COLORS,
|
||||||
|
AI_SETUP_SCOPES,
|
||||||
|
AI_SETUP_SECTIONS,
|
||||||
|
aiSetupEntries,
|
||||||
|
aiSetupProviders
|
||||||
|
} from '#lib/server/db/schema/index.js';
|
||||||
|
import { asc, eq, sql } from 'drizzle-orm';
|
||||||
|
import { Marked } from 'marked';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
// Markdown for instructions/notes. Raw HTML is shown as text, never rendered.
|
||||||
|
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
||||||
|
const marked = new Marked({
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
renderer: {
|
||||||
|
html: ({ text }) => escapeHtml(text),
|
||||||
|
link({ href, title, tokens }) {
|
||||||
|
const label = this.parser.parseInline(tokens);
|
||||||
|
if (!/^(https?:|mailto:|\/|#)/i.test(href)) return label;
|
||||||
|
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||||
|
return `<a href="${escapeHtml(href)}"${t} rel="noopener noreferrer" target="_blank">${label}</a>`;
|
||||||
|
},
|
||||||
|
image: ({ text }) => escapeHtml(text)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const renderBody = (md: string) => marked.parse(md, { async: false });
|
||||||
|
|
||||||
|
/** Common key formats. Anything matching is refused: this tab stores labels, never keys. */
|
||||||
|
const SECRET =
|
||||||
|
/(sk-(ant-|proj-)?[A-Za-z0-9_-]{16,}|xai-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AIza[0-9A-Za-z_-]{30,}|AKIA[0-9A-Z]{16}|xox[abpr]-[A-Za-z0-9-]{10,}|glpat-[A-Za-z0-9_-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.)/;
|
||||||
|
const noSecret = (v: string | null | undefined) => !v || !SECRET.test(v);
|
||||||
|
const SECRET_MSG = 'That looks like a key or token. Store a label only, never the value.';
|
||||||
|
|
||||||
|
const trimmed = (max: number) => z.string().trim().min(1, 'Name is required').max(max);
|
||||||
|
const optional = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(max)
|
||||||
|
.nullish()
|
||||||
|
.transform((v) => (v ? v : null));
|
||||||
|
const idField = z.coerce.number().int().positive();
|
||||||
|
|
||||||
|
const providerInput = z.object({
|
||||||
|
name: trimmed(60),
|
||||||
|
products: optional(120),
|
||||||
|
color: z.enum(AI_SETUP_COLORS).catch('zinc')
|
||||||
|
});
|
||||||
|
const entryInput = z
|
||||||
|
.object({
|
||||||
|
section: z.enum(AI_SETUP_SECTIONS),
|
||||||
|
name: trimmed(120),
|
||||||
|
note: optional(400),
|
||||||
|
scope: z
|
||||||
|
.enum(['', ...AI_SETUP_SCOPES])
|
||||||
|
.nullish()
|
||||||
|
.transform((v) => (v ? (v as (typeof AI_SETUP_SCOPES)[number]) : null)),
|
||||||
|
link: optional(500).refine((u) => !u || /^https?:\/\/\S+$/i.test(u), 'Link must be http(s)'),
|
||||||
|
body: optional(20000)
|
||||||
|
})
|
||||||
|
.refine((e) => noSecret(e.name) && noSecret(e.note) && noSecret(e.link) && noSecret(e.body), {
|
||||||
|
message: SECRET_MSG
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SetupEntry = {
|
||||||
|
id: number;
|
||||||
|
section: (typeof AI_SETUP_SECTIONS)[number];
|
||||||
|
name: string;
|
||||||
|
note: string | null;
|
||||||
|
scope: string | null;
|
||||||
|
link: string | null;
|
||||||
|
body: string | null;
|
||||||
|
bodyHtml: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
export type SetupProvider = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
products: string | null;
|
||||||
|
color: (typeof AI_SETUP_COLORS)[number];
|
||||||
|
updatedAt: string;
|
||||||
|
entries: SetupEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadSetup(): Promise<SetupProvider[]> {
|
||||||
|
const providers = await db
|
||||||
|
.select()
|
||||||
|
.from(aiSetupProviders)
|
||||||
|
.orderBy(asc(aiSetupProviders.sortOrder), asc(aiSetupProviders.id));
|
||||||
|
const entries = await db
|
||||||
|
.select()
|
||||||
|
.from(aiSetupEntries)
|
||||||
|
.orderBy(asc(aiSetupEntries.sortOrder), asc(aiSetupEntries.id));
|
||||||
|
return providers.map((p) => {
|
||||||
|
const own = entries.filter((e) => e.providerId === p.id);
|
||||||
|
const latest = Math.max(p.updatedAt.getTime(), ...own.map((e) => e.updatedAt.getTime()));
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
products: p.products,
|
||||||
|
color: (AI_SETUP_COLORS as readonly string[]).includes(p.color)
|
||||||
|
? (p.color as SetupProvider['color'])
|
||||||
|
: 'zinc',
|
||||||
|
updatedAt: new Date(latest).toISOString(),
|
||||||
|
entries: own.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
section: e.section as SetupEntry['section'],
|
||||||
|
name: e.name,
|
||||||
|
note: e.note,
|
||||||
|
scope: e.scope,
|
||||||
|
link: e.link,
|
||||||
|
body: e.body,
|
||||||
|
bodyHtml: e.body ? renderBody(e.body) : null,
|
||||||
|
updatedAt: e.updatedAt.toISOString()
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result = { ok: true } | { ok: false; message: string };
|
||||||
|
const firstIssue = (e: z.ZodError) => e.issues[0]?.message ?? 'Invalid input';
|
||||||
|
const fields = (form: FormData) =>
|
||||||
|
Object.fromEntries([...form.entries()].map(([k, v]) => [k, typeof v === 'string' ? v : '']));
|
||||||
|
|
||||||
|
/** Entry changes count as a change to the card ("last updated" also covers deletes). */
|
||||||
|
const touch = (providerId: number) =>
|
||||||
|
db
|
||||||
|
.update(aiSetupProviders)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(aiSetupProviders.id, providerId));
|
||||||
|
|
||||||
|
const isUniqueViolation = (e: unknown) =>
|
||||||
|
typeof e === 'object' &&
|
||||||
|
e !== null &&
|
||||||
|
('code' in e ? e.code : (e as { cause?: { code?: string } }).cause?.code) === '23505';
|
||||||
|
|
||||||
|
export async function addProvider(form: FormData): Promise<Result> {
|
||||||
|
const parsed = providerInput.safeParse(fields(form));
|
||||||
|
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||||
|
try {
|
||||||
|
const [{ next }] = await db
|
||||||
|
.select({ next: sql<number>`coalesce(max(${aiSetupProviders.sortOrder}), 0) + 10` })
|
||||||
|
.from(aiSetupProviders);
|
||||||
|
await db.insert(aiSetupProviders).values({ ...parsed.data, sortOrder: Number(next) });
|
||||||
|
} catch (e) {
|
||||||
|
if (isUniqueViolation(e)) return { ok: false, message: 'A provider with that name exists' };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProvider(form: FormData): Promise<Result> {
|
||||||
|
const id = idField.safeParse(form.get('id'));
|
||||||
|
const parsed = providerInput.safeParse(fields(form));
|
||||||
|
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||||
|
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||||
|
try {
|
||||||
|
await db.update(aiSetupProviders).set(parsed.data).where(eq(aiSetupProviders.id, id.data));
|
||||||
|
} catch (e) {
|
||||||
|
if (isUniqueViolation(e)) return { ok: false, message: 'A provider with that name exists' };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProvider(form: FormData): Promise<Result> {
|
||||||
|
const id = idField.safeParse(form.get('id'));
|
||||||
|
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||||
|
await db.delete(aiSetupProviders).where(eq(aiSetupProviders.id, id.data));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addEntry(form: FormData): Promise<Result> {
|
||||||
|
const providerId = idField.safeParse(form.get('provider_id'));
|
||||||
|
const parsed = entryInput.safeParse(fields(form));
|
||||||
|
if (!providerId.success) return { ok: false, message: 'Missing provider' };
|
||||||
|
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||||
|
const [{ next }] = await db
|
||||||
|
.select({ next: sql<number>`coalesce(max(${aiSetupEntries.sortOrder}), 0) + 10` })
|
||||||
|
.from(aiSetupEntries)
|
||||||
|
.where(eq(aiSetupEntries.providerId, providerId.data));
|
||||||
|
await db
|
||||||
|
.insert(aiSetupEntries)
|
||||||
|
.values({ ...parsed.data, providerId: providerId.data, sortOrder: Number(next) });
|
||||||
|
await touch(providerId.data);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEntry(form: FormData): Promise<Result> {
|
||||||
|
const id = idField.safeParse(form.get('id'));
|
||||||
|
const parsed = entryInput.safeParse(fields(form));
|
||||||
|
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||||
|
if (!parsed.success) return { ok: false, message: firstIssue(parsed.error) };
|
||||||
|
const [row] = await db
|
||||||
|
.update(aiSetupEntries)
|
||||||
|
.set(parsed.data)
|
||||||
|
.where(eq(aiSetupEntries.id, id.data))
|
||||||
|
.returning({ providerId: aiSetupEntries.providerId });
|
||||||
|
if (row) await touch(row.providerId);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEntry(form: FormData): Promise<Result> {
|
||||||
|
const id = idField.safeParse(form.get('id'));
|
||||||
|
if (!id.success) return { ok: false, message: 'Missing id' };
|
||||||
|
const [row] = await db
|
||||||
|
.delete(aiSetupEntries)
|
||||||
|
.where(eq(aiSetupEntries.id, id.data))
|
||||||
|
.returning({ providerId: aiSetupEntries.providerId });
|
||||||
|
if (row) await touch(row.providerId);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { check, date, doublePrecision, index, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
import {
|
||||||
|
check,
|
||||||
|
date,
|
||||||
|
doublePrecision,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
text,
|
||||||
|
uniqueIndex
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
import { aiSchema } from './schemas';
|
import { aiSchema } from './schemas';
|
||||||
import { id, timestamps, timestamptz } from './helpers';
|
import { id, timestamps, timestamptz } from './helpers';
|
||||||
|
|
||||||
@@ -82,3 +90,84 @@ export const aiUsageEntries = aiSchema.table(
|
|||||||
check('usage_entries_metric_check', sql`length(btrim(${t.metric})) > 0`)
|
check('usage_entries_metric_check', sql`length(btrim(${t.metric})) > 0`)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup tab (owner-only): how each AI provider is set up. One row per provider card; entries
|
||||||
|
* are grouped into sections. Plan/API keys are labels only: the app refuses values that look
|
||||||
|
* like a secret, and nothing here is ever a key value.
|
||||||
|
*/
|
||||||
|
export const AI_SETUP_SECTIONS = [
|
||||||
|
'connections',
|
||||||
|
'skills',
|
||||||
|
'instructions',
|
||||||
|
'plugins',
|
||||||
|
'routines',
|
||||||
|
'agents',
|
||||||
|
'plan',
|
||||||
|
'notes'
|
||||||
|
] as const;
|
||||||
|
export type AiSetupSection = (typeof AI_SETUP_SECTIONS)[number];
|
||||||
|
export const AI_SETUP_SCOPES = ['global', 'per-repo', 'app'] as const;
|
||||||
|
export type AiSetupScope = (typeof AI_SETUP_SCOPES)[number];
|
||||||
|
export const AI_SETUP_COLORS = [
|
||||||
|
'orange',
|
||||||
|
'green',
|
||||||
|
'blue',
|
||||||
|
'violet',
|
||||||
|
'pink',
|
||||||
|
'sky',
|
||||||
|
'amber',
|
||||||
|
'zinc'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const aiSetupProviders = aiSchema.table(
|
||||||
|
'provider_setup_providers',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
/** The products this card covers, e.g. "claude.ai + Claude Code". */
|
||||||
|
products: text('products'),
|
||||||
|
color: text('color').notNull().default('zinc'),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('provider_setup_providers_name_key').on(t.name),
|
||||||
|
check('provider_setup_providers_name_check', sql`length(btrim(${t.name})) > 0`),
|
||||||
|
check(
|
||||||
|
'provider_setup_providers_color_check',
|
||||||
|
sql`${t.color} IN ('orange','green','blue','violet','pink','sky','amber','zinc')`
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const aiSetupEntries = aiSchema.table(
|
||||||
|
'provider_setup_entries',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
providerId: integer('provider_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => aiSetupProviders.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
|
||||||
|
section: text('section').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
note: text('note'),
|
||||||
|
scope: text('scope'),
|
||||||
|
link: text('link'),
|
||||||
|
/** Free markdown text (custom instructions, longer notes). */
|
||||||
|
body: text('body'),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('provider_setup_entries_provider_idx').on(t.providerId, t.section, t.sortOrder),
|
||||||
|
check(
|
||||||
|
'provider_setup_entries_section_check',
|
||||||
|
sql`${t.section} IN ('connections','skills','instructions','plugins','routines','agents','plan','notes')`
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
'provider_setup_entries_scope_check',
|
||||||
|
sql`${t.scope} IS NULL OR ${t.scope} IN ('global','per-repo','app')`
|
||||||
|
),
|
||||||
|
check('provider_setup_entries_name_check', sql`length(btrim(${t.name})) > 0`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ export const ownerOnlyPrefixes = [
|
|||||||
'/trackers/books/files',
|
'/trackers/books/files',
|
||||||
// Notes (the Obsidian vault): pages, API, attachments. The /ws/notes socket checks the owner itself.
|
// Notes (the Obsidian vault): pages, API, attachments. The /ws/notes socket checks the owner itself.
|
||||||
'/trackers/notes',
|
'/trackers/notes',
|
||||||
// AI tracker: the news tab (/trackers/ai) is public, bots and usage are mine only.
|
// AI tracker: the news tab (/trackers/ai) is public, bots, usage and setup are mine only.
|
||||||
'/trackers/ai/bots',
|
'/trackers/ai/bots',
|
||||||
'/trackers/ai/usage',
|
'/trackers/ai/usage',
|
||||||
|
'/trackers/ai/setup',
|
||||||
'/lists/db',
|
'/lists/db',
|
||||||
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
|
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
|
||||||
'/api/spotify',
|
'/api/spotify',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { isOwner } from '#lib/server/owner.js';
|
import { isOwner } from '#lib/server/owner.js';
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
// /trackers/ai: the news tab is public; bots and usage are owner-only (their own loads call
|
// /trackers/ai: the news tab is public; bots, usage and setup are owner-only (their own loads call
|
||||||
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots and /trackers/ai/usage as a backstop).
|
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots, /usage and /setup as a backstop).
|
||||||
export const load: LayoutServerLoad = (event) => {
|
export const load: LayoutServerLoad = (event) => {
|
||||||
const owner = isOwner(event);
|
const owner = isOwner(event);
|
||||||
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
|
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import Newspaper from 'lucide-svelte/icons/newspaper';
|
import Newspaper from 'lucide-svelte/icons/newspaper';
|
||||||
import Bot from 'lucide-svelte/icons/bot';
|
import Bot from 'lucide-svelte/icons/bot';
|
||||||
import Gauge from 'lucide-svelte/icons/gauge';
|
import Gauge from 'lucide-svelte/icons/gauge';
|
||||||
|
import SlidersHorizontal from 'lucide-svelte/icons/sliders-horizontal';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@
|
|||||||
...(data.owner
|
...(data.owner
|
||||||
? [
|
? [
|
||||||
{ name: 'bots', href: '/trackers/ai/bots', icon: Bot, locked: true },
|
{ name: 'bots', href: '/trackers/ai/bots', icon: Bot, locked: true },
|
||||||
{ name: 'usage', href: '/trackers/ai/usage', icon: Gauge, locked: true }
|
{ name: 'usage', href: '/trackers/ai/usage', icon: Gauge, locked: true },
|
||||||
|
{ name: 'setup', href: '/trackers/ai/setup', icon: SlidersHorizontal, locked: true }
|
||||||
]
|
]
|
||||||
: [])
|
: [])
|
||||||
]);
|
]);
|
||||||
@@ -25,7 +27,10 @@
|
|||||||
<TrackerIcon name="ai" class="size-10" />
|
<TrackerIcon name="ai" class="size-10" />
|
||||||
<div>
|
<div>
|
||||||
<h1>ai</h1>
|
<h1>ai</h1>
|
||||||
<p>AI and software news, plus what my bots are doing and how much I use each model.</p>
|
<p>
|
||||||
|
AI and software news, plus what my bots are doing, how much I use each model and how each
|
||||||
|
one is set up.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<nav class="ai-tabs" aria-label="AI tracker sections">
|
<nav class="ai-tabs" aria-label="AI tracker sections">
|
||||||
@@ -118,4 +123,7 @@
|
|||||||
.tab-usage {
|
.tab-usage {
|
||||||
background: linear-gradient(135deg, #34d399, #0d9488);
|
background: linear-gradient(135deg, #34d399, #0d9488);
|
||||||
}
|
}
|
||||||
|
.tab-setup {
|
||||||
|
background: linear-gradient(135deg, #a78bfa, #6d28d9);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
44
src/routes/trackers/ai/setup/+page.server.ts
Normal file
44
src/routes/trackers/ai/setup/+page.server.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { requireOwner } from '#lib/server/owner.js';
|
||||||
|
import {
|
||||||
|
addEntry,
|
||||||
|
addProvider,
|
||||||
|
deleteEntry,
|
||||||
|
deleteProvider,
|
||||||
|
loadSetup,
|
||||||
|
updateEntry,
|
||||||
|
updateProvider
|
||||||
|
} from '#lib/server/ai-setup.js';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad, RequestEvent } from './$types';
|
||||||
|
|
||||||
|
// Owner only: how each AI provider is set up (connections, skills, instructions, ...).
|
||||||
|
// Actions don't run the load, so each one checks the owner again (hooks.server.ts also 404s
|
||||||
|
// this path for everyone else).
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||||
|
return {
|
||||||
|
providers: await loadSetup(),
|
||||||
|
now: Date.now(),
|
||||||
|
seo: { title: 'AI tracker · setup', description: 'How my AI tools are set up.', index: false }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type Handler = (form: FormData) => Promise<{ ok: true } | { ok: false; message: string }>;
|
||||||
|
const action = (fn: Handler) => async (event: RequestEvent) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const form = await event.request.formData();
|
||||||
|
const r = await fn(form);
|
||||||
|
return r.ok
|
||||||
|
? { ok: true as const }
|
||||||
|
: fail(400, { message: r.message, scope: String(form.get('form_key') ?? '') });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
addProvider: action(addProvider),
|
||||||
|
updateProvider: action(updateProvider),
|
||||||
|
deleteProvider: action(deleteProvider),
|
||||||
|
addEntry: action(addEntry),
|
||||||
|
updateEntry: action(updateEntry),
|
||||||
|
deleteEntry: action(deleteEntry)
|
||||||
|
};
|
||||||
826
src/routes/trackers/ai/setup/+page.svelte
Normal file
826
src/routes/trackers/ai/setup/+page.svelte
Normal file
@@ -0,0 +1,826 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
|
import { relativeTime } from '#lib/relative-time.js';
|
||||||
|
import Plug from 'lucide-svelte/icons/plug';
|
||||||
|
import WandSparkles from 'lucide-svelte/icons/wand-sparkles';
|
||||||
|
import ScrollText from 'lucide-svelte/icons/scroll-text';
|
||||||
|
import Puzzle from 'lucide-svelte/icons/puzzle';
|
||||||
|
import CalendarClock from 'lucide-svelte/icons/calendar-clock';
|
||||||
|
import Bot from 'lucide-svelte/icons/bot';
|
||||||
|
import KeyRound from 'lucide-svelte/icons/key-round';
|
||||||
|
import StickyNote from 'lucide-svelte/icons/sticky-note';
|
||||||
|
import Plus from 'lucide-svelte/icons/plus';
|
||||||
|
import Pencil from 'lucide-svelte/icons/pencil';
|
||||||
|
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||||
|
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
type Provider = (typeof data.providers)[number];
|
||||||
|
type Entry = Provider['entries'][number];
|
||||||
|
type SectionKey = Entry['section'];
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
key: 'connections',
|
||||||
|
label: 'Connections',
|
||||||
|
hint: 'connectors, MCP servers, integrations',
|
||||||
|
icon: Plug,
|
||||||
|
from: '#38bdf8',
|
||||||
|
to: '#2563eb'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'skills',
|
||||||
|
label: 'Skills',
|
||||||
|
hint: '',
|
||||||
|
icon: WandSparkles,
|
||||||
|
from: '#fbbf24',
|
||||||
|
to: '#ea580c'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'instructions',
|
||||||
|
label: 'Custom instructions',
|
||||||
|
hint: 'markdown',
|
||||||
|
icon: ScrollText,
|
||||||
|
from: '#a78bfa',
|
||||||
|
to: '#7c3aed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'plugins',
|
||||||
|
label: 'Plugins & extensions',
|
||||||
|
hint: '',
|
||||||
|
icon: Puzzle,
|
||||||
|
from: '#f472b6',
|
||||||
|
to: '#db2777'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'routines',
|
||||||
|
label: 'Scheduled tasks & routines',
|
||||||
|
hint: '',
|
||||||
|
icon: CalendarClock,
|
||||||
|
from: '#34d399',
|
||||||
|
to: '#059669'
|
||||||
|
},
|
||||||
|
{ key: 'agents', label: 'Agents & bots', hint: '', icon: Bot, from: '#fb923c', to: '#dc2626' },
|
||||||
|
{
|
||||||
|
key: 'plan',
|
||||||
|
label: 'Plan & API keys',
|
||||||
|
hint: 'labels only',
|
||||||
|
icon: KeyRound,
|
||||||
|
from: '#facc15',
|
||||||
|
to: '#ca8a04'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notes',
|
||||||
|
label: 'Notes',
|
||||||
|
hint: 'markdown',
|
||||||
|
icon: StickyNote,
|
||||||
|
from: '#a1a1aa',
|
||||||
|
to: '#52525b'
|
||||||
|
}
|
||||||
|
] as const satisfies readonly { key: SectionKey }[];
|
||||||
|
const MARKDOWN: SectionKey[] = ['instructions', 'notes'];
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
orange: ['#fdba74', '#c2410c'],
|
||||||
|
green: ['#6ee7b7', '#047857'],
|
||||||
|
blue: ['#93c5fd', '#1d4ed8'],
|
||||||
|
violet: ['#c4b5fd', '#6d28d9'],
|
||||||
|
pink: ['#f9a8d4', '#be185d'],
|
||||||
|
sky: ['#7dd3fc', '#0369a1'],
|
||||||
|
amber: ['#fde68a', '#b45309'],
|
||||||
|
zinc: ['#d4d4d8', '#3f3f46']
|
||||||
|
} as const;
|
||||||
|
type Color = keyof typeof COLORS;
|
||||||
|
const colorKeys = Object.keys(COLORS) as Color[];
|
||||||
|
|
||||||
|
/** Which inline form is open: p:new, p:<id>, e:<id> or n:<providerId>:<section>. */
|
||||||
|
let editing = $state<string | null>(null);
|
||||||
|
let busy = $state(false);
|
||||||
|
/** Form key of the last submit, so a validation error shows next to the right form. */
|
||||||
|
let lastKey = $state('');
|
||||||
|
|
||||||
|
const open = (key: string) => {
|
||||||
|
editing = editing === key ? null : key;
|
||||||
|
};
|
||||||
|
const submit =
|
||||||
|
(key: string, confirmText?: string): SubmitFunction =>
|
||||||
|
({ cancel }) => {
|
||||||
|
if (confirmText && !confirm(confirmText)) return cancel();
|
||||||
|
busy = true;
|
||||||
|
lastKey = key;
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
busy = false;
|
||||||
|
if (result.type === 'success') editing = null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const errorFor = (key: string) => (form?.message && lastKey === key ? form.message : null);
|
||||||
|
|
||||||
|
const grouped = (p: Provider) => {
|
||||||
|
const out = {} as Record<SectionKey, Entry[]>;
|
||||||
|
for (const e of p.entries) (out[e.section] ??= []).push(e);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
const stamp = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: 'Europe/Amsterdam'
|
||||||
|
});
|
||||||
|
const host = (url: string) => {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet providerForm(p?: Provider)}
|
||||||
|
{@const key = p ? `p:${p.id}` : 'p:new'}
|
||||||
|
<form
|
||||||
|
class="edit"
|
||||||
|
method="POST"
|
||||||
|
action={p ? '?/updateProvider' : '?/addProvider'}
|
||||||
|
use:enhance={submit(key)}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="form_key" value={key} />
|
||||||
|
{#if p}<input type="hidden" name="id" value={p.id} />{/if}
|
||||||
|
<label>name<input name="name" value={p?.name ?? ''} maxlength="60" required /></label>
|
||||||
|
<label
|
||||||
|
>products<input
|
||||||
|
name="products"
|
||||||
|
value={p?.products ?? ''}
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="e.g. app + CLI"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<fieldset class="colors">
|
||||||
|
<legend>colour</legend>
|
||||||
|
{#each colorKeys as c (c)}
|
||||||
|
<label class="color" title={c}>
|
||||||
|
<input type="radio" name="color" value={c} checked={(p?.color ?? 'zinc') === c} />
|
||||||
|
<span style:--from={COLORS[c][0]} style:--to={COLORS[c][1]}></span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="ghost" onclick={() => (editing = null)}>cancel</button>
|
||||||
|
<button type="submit" class="primary" disabled={busy}>{p ? 'save' : 'add provider'}</button>
|
||||||
|
</div>
|
||||||
|
{#if errorFor(key)}<p class="error" role="alert">{errorFor(key)}</p>{/if}
|
||||||
|
</form>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet entryForm(p: Provider, section: SectionKey, e?: Entry)}
|
||||||
|
{@const key = e ? `e:${e.id}` : `n:${p.id}:${section}`}
|
||||||
|
<form
|
||||||
|
class="edit"
|
||||||
|
method="POST"
|
||||||
|
action={e ? '?/updateEntry' : '?/addEntry'}
|
||||||
|
use:enhance={submit(key)}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="form_key" value={key} />
|
||||||
|
<input type="hidden" name="section" value={section} />
|
||||||
|
{#if e}<input type="hidden" name="id" value={e.id} />{:else}<input
|
||||||
|
type="hidden"
|
||||||
|
name="provider_id"
|
||||||
|
value={p.id}
|
||||||
|
/>{/if}
|
||||||
|
<label class="wide"
|
||||||
|
>name<input
|
||||||
|
name="name"
|
||||||
|
value={e?.name ?? ''}
|
||||||
|
maxlength="120"
|
||||||
|
required
|
||||||
|
placeholder={section === 'plan' ? 'e.g. Pro plan, or a key label like "server key"' : ''}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>scope<select name="scope" value={e?.scope ?? ''}>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="global">global</option>
|
||||||
|
<option value="per-repo">per-repo</option>
|
||||||
|
<option value="app">app</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>link<input
|
||||||
|
name="link"
|
||||||
|
type="url"
|
||||||
|
value={e?.link ?? ''}
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="https://…"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="wide"
|
||||||
|
>note<input
|
||||||
|
name="note"
|
||||||
|
value={e?.note ?? ''}
|
||||||
|
maxlength="400"
|
||||||
|
placeholder="short note"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
{#if MARKDOWN.includes(section) || e?.body}
|
||||||
|
<label class="wide"
|
||||||
|
>{section === 'instructions' ? 'instructions' : 'details'} (markdown)<textarea
|
||||||
|
name="body"
|
||||||
|
rows="6"
|
||||||
|
value={e?.body ?? ''}
|
||||||
|
maxlength="20000"></textarea></label
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if section === 'plan'}
|
||||||
|
<p class="hint wide">
|
||||||
|
<KeyRound size={12} /> Labels only: never paste a key value. Anything that looks like a key or
|
||||||
|
token is refused.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="ghost" onclick={() => (editing = null)}>cancel</button>
|
||||||
|
<button type="submit" class="primary" disabled={busy}>{e ? 'save' : 'add'}</button>
|
||||||
|
</div>
|
||||||
|
{#if errorFor(key)}<p class="error" role="alert">{errorFor(key)}</p>{/if}
|
||||||
|
</form>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<p class="meta">
|
||||||
|
{data.providers.length} providers · connections, skills and instructions per tool · labels only, never
|
||||||
|
keys
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="primary"
|
||||||
|
aria-expanded={editing === 'p:new'}
|
||||||
|
onclick={() => open('p:new')}><Plus size={15} />add provider</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if editing === 'p:new'}
|
||||||
|
<div class="card new">{@render providerForm()}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !data.providers.length}
|
||||||
|
<p class="empty">No providers yet.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
{#each data.providers as p (p.id)}
|
||||||
|
{@const groups = grouped(p)}
|
||||||
|
{@const [from, to] = COLORS[p.color as Color] ?? COLORS.zinc}
|
||||||
|
{@const emptySections = SECTIONS.filter((s) => !groups[s.key])}
|
||||||
|
<article class="card" style:--from={from} style:--to={to}>
|
||||||
|
<header class="head">
|
||||||
|
<span class="avatar" aria-hidden="true">{p.name.slice(0, 1).toUpperCase()}</span>
|
||||||
|
<div class="title">
|
||||||
|
<h2>{p.name}</h2>
|
||||||
|
{#if p.products}<p class="products">{p.products}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<time datetime={p.updatedAt} title={`${stamp(p.updatedAt)} (Amsterdam)`}
|
||||||
|
>updated {relativeTime(p.updatedAt, data.now)}</time
|
||||||
|
>
|
||||||
|
<div class="tools">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon"
|
||||||
|
aria-label="edit {p.name}"
|
||||||
|
title="edit"
|
||||||
|
onclick={() => open(`p:${p.id}`)}><Pencil size={14} /></button
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteProvider"
|
||||||
|
use:enhance={submit(
|
||||||
|
`pd:${p.id}`,
|
||||||
|
`Delete ${p.name} and its ${p.entries.length} entries?`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={p.id} />
|
||||||
|
<button type="submit" class="icon danger" aria-label="delete {p.name}" title="delete"
|
||||||
|
><Trash2 size={14} /></button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{#if editing === `p:${p.id}`}{@render providerForm(p)}{/if}
|
||||||
|
|
||||||
|
{#each SECTIONS as s (s.key)}
|
||||||
|
{@const list = groups[s.key]}
|
||||||
|
{#if list || editing === `n:${p.id}:${s.key}`}
|
||||||
|
<details class="section" open={(list?.length ?? 0) <= 8}>
|
||||||
|
<summary>
|
||||||
|
<span class="sicon" style:--from={s.from} style:--to={s.to}><s.icon size={13} /></span
|
||||||
|
>
|
||||||
|
<span class="slabel">{s.label}</span>
|
||||||
|
{#if s.hint}<span class="shint">{s.hint}</span>{/if}
|
||||||
|
<span class="count">{list?.length ?? 0}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon add"
|
||||||
|
aria-label="add to {s.label}"
|
||||||
|
title="add"
|
||||||
|
onclick={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
open(`n:${p.id}:${s.key}`);
|
||||||
|
}}><Plus size={14} /></button
|
||||||
|
>
|
||||||
|
</summary>
|
||||||
|
{#if editing === `n:${p.id}:${s.key}`}{@render entryForm(p, s.key)}{/if}
|
||||||
|
{#if list}
|
||||||
|
<ul class="entries">
|
||||||
|
{#each list as e (e.id)}
|
||||||
|
<li class="entry">
|
||||||
|
{#if editing === `e:${e.id}`}
|
||||||
|
{@render entryForm(p, s.key, e)}
|
||||||
|
{:else}
|
||||||
|
<div class="eline">
|
||||||
|
<span class="ename">{e.name}</span>
|
||||||
|
{#if e.scope}<span class="scope scope-{e.scope}">{e.scope}</span>{/if}
|
||||||
|
{#if e.link}
|
||||||
|
<a
|
||||||
|
class="elink"
|
||||||
|
href={e.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={e.link}><ExternalLink size={12} />{host(e.link)}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span class="etools">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon"
|
||||||
|
aria-label="edit {e.name}"
|
||||||
|
title="edit"
|
||||||
|
onclick={() => open(`e:${e.id}`)}><Pencil size={13} /></button
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteEntry"
|
||||||
|
use:enhance={submit(`ed:${e.id}`, `Delete “${e.name}”?`)}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={e.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="icon danger"
|
||||||
|
aria-label="delete {e.name}"
|
||||||
|
title="delete"><Trash2 size={13} /></button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if e.note}<p class="enote">{e.note}</p>{/if}
|
||||||
|
{#if e.bodyHtml}
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- owner-written markdown, rendered on the server with raw HTML escaped -->
|
||||||
|
<div class="md">{@html e.bodyHtml}</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if s.key === 'plan'}
|
||||||
|
<p class="hint"><KeyRound size={12} /> Labels only, never key values.</p>
|
||||||
|
{/if}
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if emptySections.some((s) => editing !== `n:${p.id}:${s.key}`)}
|
||||||
|
<div class="chips">
|
||||||
|
{#each emptySections as s (s.key)}
|
||||||
|
{#if editing !== `n:${p.id}:${s.key}`}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip"
|
||||||
|
onclick={() => open(`n:${p.id}:${s.key}`)}
|
||||||
|
style:--from={s.from}
|
||||||
|
style:--to={s.to}><span class="sicon"><s.icon size={11} /></span>{s.label}</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.meta,
|
||||||
|
.empty {
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--site-text);
|
||||||
|
color: var(--site-bg);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.primary:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.ghost {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.ghost:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
border-color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
|
}
|
||||||
|
.icon:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
background: color-mix(in srgb, var(--site-text) 8%, transparent);
|
||||||
|
}
|
||||||
|
.icon.danger:hover {
|
||||||
|
color: #f87171;
|
||||||
|
background: rgb(248 113 113 / 0.1);
|
||||||
|
}
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 26rem), 1fr));
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
grid-template-areas: 'avatar title tools' 'avatar time tools';
|
||||||
|
column-gap: 0.7rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
grid-area: avatar;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
background: linear-gradient(135deg, var(--from), var(--to));
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
grid-area: title;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.products {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.head time {
|
||||||
|
grid-area: time;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.tools {
|
||||||
|
grid-area: tools;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
border-top: 1px solid var(--site-border);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sicon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--from), var(--to));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.slabel {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.shint {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 0.05rem 0.45rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.entries {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.entry {
|
||||||
|
padding: 0.35rem 0 0.35rem 1.85rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
}
|
||||||
|
.entry + .entry {
|
||||||
|
border-top: 1px dashed color-mix(in srgb, var(--site-border) 70%, transparent);
|
||||||
|
}
|
||||||
|
.eline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.ename {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.scope {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.scope-global {
|
||||||
|
color: #38bdf8;
|
||||||
|
border-color: rgb(56 189 248 / 0.4);
|
||||||
|
}
|
||||||
|
.scope-per-repo {
|
||||||
|
color: #a78bfa;
|
||||||
|
border-color: rgb(167 139 250 / 0.4);
|
||||||
|
}
|
||||||
|
.scope-app {
|
||||||
|
color: #34d399;
|
||||||
|
border-color: rgb(52 211 153 / 0.4);
|
||||||
|
}
|
||||||
|
.elink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.elink:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.etools {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.entry:hover .etools,
|
||||||
|
.entry:focus-within .etools {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@media (hover: none) {
|
||||||
|
.etools {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.enote {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.md {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border-left: 2px solid var(--site-border);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.md :global(p + p),
|
||||||
|
.md :global(ul),
|
||||||
|
.md :global(ol) {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.md :global(ul) {
|
||||||
|
list-style: disc;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
}
|
||||||
|
.md :global(ol) {
|
||||||
|
list-style: decimal;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
}
|
||||||
|
.md :global(code) {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background: color-mix(in srgb, var(--site-text) 8%, transparent);
|
||||||
|
}
|
||||||
|
.md :global(a) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-top: 1px solid var(--site-border);
|
||||||
|
padding-top: 0.55rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.2rem 0.6rem 0.2rem 0.25rem;
|
||||||
|
border: 1px dashed var(--site-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
border-color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.chip .sicon {
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.edit {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 10rem), 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: end;
|
||||||
|
padding: 0.7rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: var(--site-bg);
|
||||||
|
}
|
||||||
|
.entry .edit {
|
||||||
|
margin-left: -1.85rem;
|
||||||
|
}
|
||||||
|
.edit label,
|
||||||
|
.colors legend {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.edit .wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.edit input:not([type='radio']),
|
||||||
|
.edit select,
|
||||||
|
.edit textarea {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
color: var(--site-text);
|
||||||
|
background: var(--site-surface);
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.edit textarea {
|
||||||
|
resize: vertical;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.colors {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.colors legend {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.color input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.color span {
|
||||||
|
display: block;
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
background: linear-gradient(135deg, var(--from), var(--to));
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.color input:checked + span {
|
||||||
|
outline-color: var(--site-text);
|
||||||
|
}
|
||||||
|
.color input:focus-visible + span {
|
||||||
|
outline-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #ca8a04;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.edit .hint {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #f87171;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.new {
|
||||||
|
padding: 0.2rem 0.85rem 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user