diff --git a/.github/workflows/add-icon.yml b/.github/workflows/add-icon.yml new file mode 100644 index 00000000..5f0828cb --- /dev/null +++ b/.github/workflows/add-icon.yml @@ -0,0 +1,91 @@ +name: Add Icon to Collection + +on: + workflow_call: + inputs: + submissionId: + description: ID of the PocketBase submission to import + required: true + type: string + dryRun: + description: When true, skip writes/commit/PocketBase status update + required: false + default: false + type: boolean + secrets: + PB_URL: + required: true + PB_ADMIN_TOKEN: + required: true + workflow_dispatch: + inputs: + submissionId: + description: ID of the PocketBase submission to import + required: true + type: string + dryRun: + description: When true, skip writes/commit/PocketBase status update + required: false + default: false + type: boolean + +jobs: + add-icon: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + + - name: Import icon from PocketBase submission + id: import + env: + PB_URL: ${{ secrets.PB_URL }} + PB_ADMIN_TOKEN: ${{ secrets.PB_ADMIN_TOKEN }} + run: | + ARGS=(--submission-id "${{ inputs.submissionId }}") + if [ "${{ inputs.dryRun }}" = "true" ]; then + ARGS+=("--dry-run") + fi + ARGS+=("--gha-output" "$GITHUB_OUTPUT") + bun run scripts/import-icon.ts "${ARGS[@]}" + + - name: Check for changes + id: git-diff + run: | + if git diff --quiet; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + + - name: Configure git user + if: ${{ !inputs.dryRun && steps.git-diff.outputs.has_changes == 'true' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Commit changes + if: ${{ !inputs.dryRun && steps.git-diff.outputs.has_changes == 'true' }} + run: | + submission_name="${{ steps.import.outputs.submission_name }}" + if [ -z "$submission_name" ]; then + submission_name="${{ inputs.submissionId }}" + fi + approver="${{ steps.import.outputs.approver }}" + if [ -z "$approver" ]; then + approver="unknown" + fi + git add . + git commit -m "chore: add icon \"${submission_name}\" (submission ${{ inputs.submissionId }}, approved by ${approver})" + + - name: Push changes + if: ${{ !inputs.dryRun && steps.git-diff.outputs.has_changes == 'true' }} + run: git push + diff --git a/.gitignore b/.gitignore index c3dba2a7..f767e14e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,6 @@ Temporary Items *.icloud # Python -__pycache__/ \ No newline at end of file +__pycache__/ + +node_modules/ \ No newline at end of file diff --git a/docs/add-icon-workflow.md b/docs/add-icon-workflow.md new file mode 100644 index 00000000..10e96514 --- /dev/null +++ b/docs/add-icon-workflow.md @@ -0,0 +1,42 @@ +# Add Icon Workflow Usage + +This repository exposes `.github/workflows/add-icon.yml` to import an accepted PocketBase submission into the collection, commit assets/metadata, and mark the submission as `added_to_collection`. + +## Required secrets +- `PB_URL`: PocketBase base URL (e.g., `https://pb.example.com`). +- `PB_ADMIN_TOKEN`: PocketBase superuser/impersonation token with permission to read submissions and update their status. + +## Inputs +- `submissionId` (string, required): PocketBase submission record ID. +- `dryRun` (boolean, optional, default `false`): Skip writes, commit, and status update. + +## Trigger via GitHub API (backend/webhook) +Use a token with `workflow` scope: +```bash +curl -X POST \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos///actions/workflows/add-icon.yml/dispatches \ + -d '{ + "ref": "main", + "inputs": { + "submissionId": "SUBMISSION_RECORD_ID", + "dryRun": "false" + } + }' +``` + +## Trigger from another workflow +```yaml +jobs: + add-icon: + uses: ./.github/workflows/add-icon.yml + with: + submissionId: ${{ github.event.inputs.submissionId }} + dryRun: false + secrets: + PB_URL: ${{ secrets.PB_URL }} + PB_ADMIN_TOKEN: ${{ secrets.PB_ADMIN_TOKEN }} +``` + + diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..21c874c8 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +act = "latest" diff --git a/package.json b/package.json new file mode 100644 index 00000000..2cf428e4 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "@types/bun": "^1.3.5", + "@types/node": "^25.0.3" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..70099ee7 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,46 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/bun': + specifier: ^1.3.5 + version: 1.3.5 + '@types/node': + specifier: ^25.0.3 + version: 25.0.3 + +packages: + + '@types/bun@1.3.5': + resolution: {integrity: sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w==} + + '@types/node@25.0.3': + resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + + bun-types@1.3.5: + resolution: {integrity: sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + +snapshots: + + '@types/bun@1.3.5': + dependencies: + bun-types: 1.3.5 + + '@types/node@25.0.3': + dependencies: + undici-types: 7.16.0 + + bun-types@1.3.5: + dependencies: + '@types/node': 25.0.3 + + undici-types@7.16.0: {} diff --git a/scripts/import-icon.ts b/scripts/import-icon.ts new file mode 100644 index 00000000..6168e04c --- /dev/null +++ b/scripts/import-icon.ts @@ -0,0 +1,470 @@ +#!/usr/bin/env bun + +import { mkdir } from "node:fs/promises"; + +import path from "node:path"; + +type IconColors = { light?: string; dark?: string }; +type IconWordmark = { light?: string; dark?: string }; + +interface PBUser { + id: string; + username?: string; + email?: string; +} + +interface SubmissionExtras { + aliases?: string[]; + categories?: string[]; + base?: string; + colors?: IconColors; + wordmark?: IconWordmark; +} + +interface Submission { + id: string; + name: string; + assets: string[]; + created_by: string; + status: string; + extras?: SubmissionExtras; + approved_by?: string; + description?: string; + admin_comment?: string; + expand?: { + created_by?: PBUser; + approved_by?: PBUser; + }; +} + +interface Args { + submissionId: string; + dryRun: boolean; + ghaOutputPath?: string; +} + +interface MetadataAuthor { + id: string | number; + name?: string; + login?: string; +} + +interface MetadataEntry { + base: string; + aliases: string[]; + categories: string[]; + update: { + timestamp: string; + author: MetadataAuthor; + }; + colors?: IconColors; + wordmark?: IconWordmark; +} + +type VariantKey = + | "base" + | "light" + | "dark" + | "wordmark-light" + | "wordmark-dark"; + +interface VariantTarget { + key: VariantKey; + destFilename: string; + matchers: string[]; // lowercase substrings to prefer when selecting assets + sourceAsset?: string; // chosen source asset name +} + +const PB_URL = process.env.PB_URL; +const PB_ADMIN_TOKEN = process.env.PB_ADMIN_TOKEN; +const ICONS_DIR = path.resolve(process.cwd(), "icons"); +const METADATA_PATH = path.resolve(process.cwd(), "metadata.json"); + +function parseArgs(argv: string[]): Args { + let submissionId: string | undefined; + let dryRun = false; + let ghaOutputPath: string | undefined = process.env.GITHUB_OUTPUT; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--submission-id" && argv[i + 1]) { + submissionId = argv[i + 1]; + i++; + } else if (arg === "--dry-run") { + dryRun = true; + } else if (arg === "--gha-output" && argv[i + 1]) { + ghaOutputPath = argv[i + 1]; + i++; + } + } + + if (!submissionId) { + throw new Error("Missing required --submission-id"); + } + + return { submissionId, dryRun, ghaOutputPath }; +} + +function requireEnv(name: string, value: string | undefined): string { + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } + return value.replace(/\/+$/, ""); +} + +async function fetchSubmission(pbUrl: string, id: string): Promise { + const url = `${pbUrl}/api/collections/submissions/records/${id}?expand=created_by,approved_by`; + console.log(`[import-icon] Fetching submission from ${url}`); + const res = await fetch(url, { + headers: { Authorization: PB_ADMIN_TOKEN ?? "" }, + }); + + if (!res.ok) { + const body = await res.text(); + console.error( + `[import-icon] fetch submission failed: status=${res.status} body=${body}`, + ); + throw new Error(`Failed to fetch submission ${id}: ${res.status} ${body}`); + } + + return (await res.json()) as Submission; +} + +async function ensureDir(dir: string) { + await mkdir(dir, { recursive: true }); +} + +async function fileExists(filePath: string): Promise { + return await Bun.file(filePath).exists(); +} + +function inferBase(assets: string[], extrasBase?: string) { + if (extrasBase) return extrasBase; + const first = assets[0]; + const ext = path.extname(first).replace(".", "").toLowerCase(); + return ext || "svg"; +} + +function buildTargets(submission: Submission): VariantTarget[] { + const iconId = submission.name; + const ext = inferBase(submission.assets, submission.extras?.base); + const assetsLower = submission.assets.map((a) => a.toLowerCase()); + const hasLight = + submission.extras?.colors?.light || + assetsLower.some((a) => a.includes("light")); + const hasDark = + submission.extras?.colors?.dark || + assetsLower.some((a) => a.includes("dark")); + const hasWordmark = + submission.extras?.wordmark || + assetsLower.some((a) => a.includes("wordmark")); + const hasWordmarkLight = + submission.extras?.wordmark?.light || + assetsLower.some((a) => a.includes("wordmark") && a.includes("light")); + const hasWordmarkDark = + submission.extras?.wordmark?.dark || + assetsLower.some((a) => a.includes("wordmark") && a.includes("dark")); + + const targets: VariantTarget[] = [ + { key: "base", destFilename: `${iconId}.${ext}`, matchers: [] }, + ]; + + if (hasLight) { + targets.push({ + key: "light", + destFilename: `${iconId}-light.${ext}`, + matchers: ["light"], + }); + } + + if (hasDark) { + targets.push({ + key: "dark", + destFilename: `${iconId}-dark.${ext}`, + matchers: ["dark"], + }); + } + + if (hasWordmark || hasWordmarkLight || hasWordmarkDark) { + if (hasWordmarkLight || hasWordmark) { + targets.push({ + key: "wordmark-light", + destFilename: `${iconId}-wordmark-light.${ext}`, + matchers: ["wordmark", "light"], + }); + } + + if (hasWordmarkDark || hasWordmark) { + targets.push({ + key: "wordmark-dark", + destFilename: `${iconId}-wordmark-dark.${ext}`, + matchers: ["wordmark", "dark"], + }); + } + } + + return targets; +} + +function assignAssetsToTargets( + assets: string[], + targets: VariantTarget[], +): VariantTarget[] { + const remaining = new Set(assets); + + const takeMatching = (matchers: string[]): string | undefined => { + for (const asset of remaining) { + const lower = asset.toLowerCase(); + const allMatch = matchers.every((m) => lower.includes(m)); + if (allMatch) { + remaining.delete(asset); + return asset; + } + } + return undefined; + }; + + const takeAny = (): string | undefined => { + const first = remaining.values().next().value as string | undefined; + if (first) remaining.delete(first); + return first; + }; + + return targets.map((t) => { + const matched = t.matchers.length ? takeMatching(t.matchers) : takeAny(); + const fallback = matched ?? takeAny(); + return { ...t, sourceAsset: fallback }; + }); +} + +async function downloadAsset( + pbUrl: string, + submissionId: string, + filename: string, + destPath: string, +) { + const url = `${pbUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}`; + const res = await fetch(url, { + headers: { Authorization: PB_ADMIN_TOKEN ?? "" }, + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error( + `Failed to download asset ${filename}: ${res.status} ${body}`, + ); + } + + const buffer = await res.arrayBuffer(); + await Bun.write(destPath, buffer); +} + +async function readMetadata(): Promise> { + const file = Bun.file(METADATA_PATH); + if (!(await file.exists())) { + return {}; + } + const raw = await file.text(); + return JSON.parse(raw) as Record; +} + +async function writeMetadata(data: Record) { + const json = `${JSON.stringify(data, null, 4)}\n`; + await Bun.write(METADATA_PATH, json); +} + +function buildAuthor(submission: Submission): MetadataAuthor { + const creator = submission.expand?.created_by; + if (!creator) { + return { id: submission.created_by }; + } + + return { + id: creator.id, + ...(creator.username ? { name: creator.username } : {}), + ...(creator.email ? { login: creator.email } : {}), + }; +} + +function buildMetadataVariants(assignments: VariantTarget[]) { + const colors: IconColors = {}; + const wordmark: IconWordmark = {}; + + for (const v of assignments) { + const baseName = v.destFilename.replace(/\.[^.]+$/, ""); + if (v.key === "light") { + colors.light = baseName; + } else if (v.key === "dark") { + colors.dark = baseName; + } else if (v.key === "wordmark-light") { + wordmark.light = baseName; + } else if (v.key === "wordmark-dark") { + wordmark.dark = baseName; + } + } + + return { + colors: Object.keys(colors).length ? colors : undefined, + wordmark: Object.keys(wordmark).length ? wordmark : undefined, + }; +} + +async function upsertMetadata( + submission: Submission, + assignments: VariantTarget[], + dryRun: boolean, +) { + const iconId = submission.name; + const base = inferBase(submission.assets, submission.extras?.base); + const aliases = submission.extras?.aliases ?? []; + const categories = submission.extras?.categories ?? []; + const { colors, wordmark } = buildMetadataVariants(assignments); + const author = buildAuthor(submission); + + console.log( + `[import-icon] Upserting metadata for "${iconId}" base=${base} aliases=${aliases.length} categories=${categories.length}`, + ); + + const data = await readMetadata(); + const nextEntry: MetadataEntry = { + base, + aliases, + categories, + update: { + timestamp: new Date().toISOString(), + author, + }, + ...(colors ? { colors } : {}), + ...(wordmark ? { wordmark } : {}), + }; + + data[iconId] = nextEntry; + + if (dryRun) { + console.log(`[dry-run] Would upsert metadata for icon "${iconId}"`); + return; + } + + await writeMetadata(data); + console.log(`Updated metadata for icon "${iconId}"`); +} + +async function persistAssets( + pbUrl: string, + submission: Submission, + dryRun: boolean, +) { + if (submission.assets.length === 0) { + throw new Error("Submission has no assets to import"); + } + + const targets = buildTargets(submission); + const assignments = assignAssetsToTargets(submission.assets, targets); + + for (const target of assignments) { + if (!target.sourceAsset) { + console.warn( + `[import-icon] No asset available for variant ${target.key}; skipping`, + ); + continue; + } + + const destPath = path.join(ICONS_DIR, target.destFilename); + console.log( + `[import-icon] Handling asset ${target.sourceAsset} -> ${destPath} (variant ${target.key})`, + ); + const exists = await fileExists(destPath); + if (exists) { + console.log(`Skipping existing asset ${destPath}`); + continue; + } + + if (dryRun) { + console.log( + `[dry-run] Would download ${target.sourceAsset} -> ${destPath}`, + ); + continue; + } + + await ensureDir(ICONS_DIR); + await downloadAsset(pbUrl, submission.id, target.sourceAsset, destPath); + console.log(`Downloaded ${target.sourceAsset} -> ${destPath}`); + } + + return assignments; +} + +async function markSubmissionAdded( + pbUrl: string, + submissionId: string, + dryRun: boolean, +) { + if (dryRun) { + console.log( + `[dry-run] Would mark submission ${submissionId} as added_to_collection`, + ); + return; + } + + const res = await fetch( + `${pbUrl}/api/collections/submissions/records/${submissionId}`, + { + method: "PATCH", + headers: { + Authorization: PB_ADMIN_TOKEN ?? "", + "Content-Type": "application/json", + }, + body: JSON.stringify({ status: "added_to_collection" }), + }, + ); + + if (!res.ok) { + const body = await res.text(); + console.error( + `[import-icon] status update failed: status=${res.status} body=${body}`, + ); + throw new Error( + `Failed to update submission status: ${res.status} ${body}`, + ); + } + + console.log(`Marked submission ${submissionId} as added_to_collection`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const pbUrl = requireEnv("PB_URL", PB_URL); + requireEnv("PB_ADMIN_TOKEN", PB_ADMIN_TOKEN); + + console.log( + `[import-icon] Starting import submissionId=${args.submissionId} dryRun=${args.dryRun} iconsDir=${ICONS_DIR} metadata=${METADATA_PATH}`, + ); + + console.log(`Fetching submission ${args.submissionId}...`); + const submission = await fetchSubmission(pbUrl, args.submissionId); + + const approver = + submission.expand?.approved_by?.username || + submission.expand?.approved_by?.email || + submission.approved_by || + "unknown"; + + const assignments = await persistAssets(pbUrl, submission, args.dryRun); + await upsertMetadata(submission, assignments, args.dryRun); + await markSubmissionAdded(pbUrl, args.submissionId, args.dryRun); + + if (args.ghaOutputPath) { + const lines = [ + `submission_name=${submission.name}`, + `approver=${approver}`, + ].join("\n"); + await Bun.write(args.ghaOutputPath, new TextEncoder().encode(`${lines}\n`)); + } + + console.log("Import completed."); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/web/src/components/icon-submission-form.tsx b/web/src/components/icon-submission-form.tsx index 76897d46..35c263a9 100644 --- a/web/src/components/icon-submission-form.tsx +++ b/web/src/components/icon-submission-form.tsx @@ -1,10 +1,76 @@ -"use client" +"use client"; import { ExternalLink } from "lucide-react"; import Link from "next/link"; +import { z } from "zod"; import { Button } from "@/components/ui/button"; import { REPO_PATH } from "@/constants"; +const userSchema = z.object({ + id: z.string(), + username: z.string(), + email: z.string().email(), + admin: z.boolean().optional(), + avatar: z.string().optional(), + created: z.string(), + updated: z.string(), +}); + +const submissionExtrasSchema = z.object({ + aliases: z.array(z.string()).default([]), + categories: z.array(z.string()).default([]), + base: z.string().optional(), + colors: z + .object({ + dark: z.string().optional(), + light: z.string().optional(), + }) + .optional(), + wordmark: z + .object({ + dark: z.string().optional(), + light: z.string().optional(), + }) + .optional(), +}); + +export const submissionSchema = z.object({ + id: z.string().optional(), + name: z.string().min(1, "Icon name is required"), + assets: z + .array( + z.union([ + z.string(), + // Accept File objects while data is still client-side + z.instanceof(File), + ]), + ) + .min(1, "At least one asset is required"), + created_by: z.string().optional(), + status: z + .enum(["approved", "rejected", "pending", "added_to_collection"]) + .default("pending"), + approved_by: z.string().optional(), + extras: submissionExtrasSchema.default({ + aliases: [], + categories: [], + }), + expand: z + .object({ + created_by: userSchema.optional(), + approved_by: userSchema.optional(), + }) + .optional(), + created: z.string().optional(), + updated: z.string().optional(), + admin_comment: z.string().optional().default(""), + description: z.string().optional().default(""), +}); + +export type SubmissionInput = z.infer; +export const parseSubmission = (input: unknown) => + submissionSchema.parse(input); + export const ISSUE_TEMPLATES = [ { id: "add_monochrome_icon", diff --git a/web/tsconfig.json b/web/tsconfig.json index d7fc3a47..ed143135 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,5 +1,8 @@ { "compilerOptions": { + "types": [ + "bun-types" + ], "target": "ES2017", "lib": [ "dom",