From 5653da4591e42061bc7b4d8b5d2014ac6744cbc9 Mon Sep 17 00:00:00 2001 From: Patrick Taylor <1963845+pstaylor-patrick@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:52:57 -0600 Subject: [PATCH 1/5] feat: add CI workflow and code formatting tools - Add GitHub Actions workflow for pull request quality gates including format check, linting, type checking, build, and tests - Add Prettier for code formatting with format check and format scripts - Add prettierignore file to exclude generated files from formatting - Update .env.example to remove deprecated npx auth secret command reference - Add test scripts for unit and e2e testing - Update package dependencies to include Prettier These changes improve code quality enforcement through automated CI checks and consistent code formatting across the project. --- .env.example | 2 +- .github/workflows/pr.yaml | 110 ++++++++++++++++++++++++++++++++++++++ .prettierignore | 7 +++ package-lock.json | 17 ++++++ package.json | 5 ++ 5 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pr.yaml create mode 100644 .prettierignore diff --git a/.env.example b/.env.example index 803ee56..0da2ffe 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/postgres -# `npx auth secret` or `openssl rand -hex 32` +# `openssl rand -hex 32` NEXTAUTH_SECRET=**** AUTH_PROVIDER_URL=https://auth.f3nation.com diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..f37873e --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,110 @@ +name: Pull Request Quality Gates + +on: + pull_request: + +env: + DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/postgres + NEXTAUTH_SECRET: "****" + AUTH_PROVIDER_URL: https://auth.f3nation.com + NEXTAUTH_URL: https://localhost:3001 + NEXT_PUBLIC_NEXTAUTH_URL: https://localhost:3001 + OAUTH_REDIRECT_URI: https://localhost:3001/callback + OAUTH_CLIENT_ID: local-client + OAUTH_CLIENT_SECRET: "****" + +jobs: + format-check: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Format check + run: npm run format:check + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + typecheck: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run typecheck + + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm run test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9658f05 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +.next +out +dist +build +coverage +pglite-debug.log diff --git a/package-lock.json b/package-lock.json index 8ebd6ac..05a85e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "genkit-cli": "^1.8.0", "node-pg-migrate": "^7.9.1", "postcss": "^8", + "prettier": "^3.6.2", "tailwindcss": "^3.4.1", "ts-node": "^10.9.2", "tsx": "^4.20.3", @@ -18626,6 +18627,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", diff --git a/package.json b/package.json index 62f263f..24a8a8b 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,12 @@ "export": "next export", "deploy": "npm run build && firebase deploy --only apphosting:the-codex", "start": "next start", + "format:check": "prettier --check .", + "format": "prettier --write .", "lint": "next lint", "typecheck": "tsc --noEmit", + "test": "node --test", + "test:e2e": "npx --yes playwright test", "firebase:secrets": "/usr/bin/env bash scripts/firebase-secrets.sh", "db:generate:migration": "tsx scripts/db-generate-migration.ts", "db:migrate": "tsx scripts/db-migrate.ts up", @@ -85,6 +89,7 @@ "eslint-config-next": "16.0.1", "genkit-cli": "^1.8.0", "node-pg-migrate": "^7.9.1", + "prettier": "^3.6.2", "postcss": "^8", "tailwindcss": "^3.4.1", "ts-node": "^10.9.2", From 18e366bb50f2b02cddb7e911744e1f34d373f09c Mon Sep 17 00:00:00 2001 From: Patrick Taylor <1963845+pstaylor-patrick@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:57:56 -0600 Subject: [PATCH 2/5] Fix Errs Caught by PR Quality Gates --- README.md | 20 +- components.json | 2 +- docs/blueprint.md | 2 +- eslint.config.mjs | 2 +- middleware.ts | 32 +- .../1747064544503_create-tags-table.cjs | 72 +- ...64552223_create-user-submissions-table.cjs | 10 +- ...15_create-entries-and-entry-tags-table.cjs | 10 +- ...732000000_add-mentioned-entries-column.cjs | 8 +- ...32100000_create-entry-references-table.cjs | 30 +- .../1762262413053_add-performance-indexes.cjs | 12 +- next.config.ts | 38 +- public/404.html | 95 +- scripts/db-generate-migration.ts | 42 +- scripts/db-migrate.ts | 136 +- src/ai/dev.ts | 4 +- src/ai/flows/auto-link-references.ts | 42 +- src/ai/genkit.ts | 6 +- src/app/actions.ts | 9 +- src/app/admin/AdminPanel.tsx | 2502 ++++++++++------- src/app/admin/actions.ts | 109 +- src/app/admin/page.tsx | 17 +- src/app/api/callback/route.ts | 36 +- src/app/api/debug/route.ts | 14 +- src/app/api/entries/[entryId]/route.ts | 46 +- src/app/api/exicon/[id]/route.ts | 51 +- src/app/api/exicon/route.ts | 38 +- src/app/api/lexicon/route.ts | 38 +- .../api/test-db-connection/import-lexicon.ts | 344 ++- src/app/api/test-db-connection/route.ts | 137 +- src/app/callback/page.tsx | 87 +- src/app/exicon-2/[entryId]/page.tsx | 248 +- src/app/exicon-2/page.tsx | 224 +- src/app/exicon/ExiconClientPageContent.tsx | 248 +- src/app/exicon/[entryId]/page.tsx | 246 +- src/app/exicon/page.tsx | 139 +- src/app/faq/page.tsx | 364 ++- src/app/layout.tsx | 26 +- src/app/lexicon-2/[entryId]/page.tsx | 152 +- src/app/lexicon-2/page.tsx | 149 +- src/app/lexicon/LexiconClientPageContent.tsx | 235 +- src/app/lexicon/[entryId]/page.tsx | 137 +- src/app/lexicon/page.tsx | 104 +- src/app/page.tsx | 33 +- src/app/submit/actions.ts | 126 +- src/app/submit/page.tsx | 11 +- src/components/admin/EntryForm.tsx | 227 +- src/components/exicon/ExiconDisplay.tsx | 88 +- src/components/exicon/TagFilter.tsx | 26 +- src/components/layout/ConditionalLayout.tsx | 16 +- src/components/layout/Header.tsx | 18 +- src/components/layout/PageContainer.tsx | 7 +- src/components/lexicon/LexiconDisplay.tsx | 105 +- src/components/shared/AILinkedText.tsx | 2 +- src/components/shared/BackButton.tsx | 26 +- src/components/shared/CopyEntryUrlButton.tsx | 24 +- src/components/shared/CopyLinkButton.tsx | 74 +- src/components/shared/EntryCard.tsx | 500 ++-- src/components/shared/EntryGrid.tsx | 28 +- .../shared/MentionTextArea copy.tsx | 225 +- src/components/shared/MentionTextArea.tsx | 68 +- src/components/shared/SearchBar.tsx | 12 +- src/components/shared/SuggestEditsButton.tsx | 24 +- src/components/submission/SubmissionForm.tsx | 233 +- .../submission/SubmissionFormWrapper.tsx | 8 +- .../submission/SuggestionEditForm.tsx | 262 +- src/components/ui/accordion.tsx | 28 +- src/components/ui/alert-dialog.tsx | 60 +- src/components/ui/alert.tsx | 24 +- src/components/ui/avatar.tsx | 26 +- src/components/ui/badge.tsx | 14 +- src/components/ui/button.tsx | 26 +- src/components/ui/calendar.tsx | 24 +- src/components/ui/card.tsx | 41 +- src/components/ui/chart.tsx | 170 +- src/components/ui/checkbox.tsx | 18 +- src/components/ui/dialog.tsx | 64 +- src/components/ui/dropdown-menu.tsx | 82 +- src/components/ui/form.tsx | 103 +- src/components/ui/hover-card copy.tsx | 34 +- src/components/ui/hover-card.tsx | 34 +- src/components/ui/input.tsx | 16 +- src/components/ui/label.tsx | 20 +- src/components/ui/menubar.tsx | 98 +- src/components/ui/popover.tsx | 20 +- src/components/ui/progress.tsx | 16 +- src/components/ui/radio-group.tsx | 26 +- src/components/ui/scroll-area.tsx | 20 +- src/components/ui/select.tsx | 58 +- src/components/ui/separator.tsx | 20 +- src/components/ui/sheet.tsx | 56 +- src/components/ui/sidebar.tsx | 380 +-- src/components/ui/skeleton.tsx | 6 +- src/components/ui/slider.tsx | 16 +- src/components/ui/switch.tsx | 18 +- src/components/ui/table.tsx | 44 +- src/components/ui/tabs.tsx | 30 +- src/components/ui/textarea.tsx | 14 +- src/components/ui/toast.tsx | 56 +- src/components/ui/toaster.tsx | 12 +- src/components/ui/tooltip.tsx | 22 +- src/hooks/use-mobile.tsx | 24 +- src/hooks/use-toast.ts | 131 +- src/lib/api.ts | 737 +++-- src/lib/auth.ts | 23 +- src/lib/clipboard.ts | 53 +- src/lib/data.ts | 36 +- src/lib/db.ts | 14 +- src/lib/env.ts | 28 +- src/lib/firebaseAdmin.ts | 16 +- src/lib/initalEntries.ts | 782 +++--- src/lib/mentionUtils.ts | 62 +- src/lib/route-utils.ts | 51 +- src/lib/types.ts | 39 +- src/lib/utils.ts | 55 +- tailwind.config.ts | 166 +- tsconfig.json | 21 +- tsconfig.tsbuildinfo | 2 +- uploadLexicon.js | 35 +- 119 files changed, 7265 insertions(+), 5112 deletions(-) diff --git a/README.md b/README.md index 64ed244..4ae2a83 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,12 @@ The Exicon and Lexicon pages support URL query parameters for filtering and sear The Exicon (`/exicon`) supports the following query parameters: -| Parameter | Type | Description | Default | -|-----------|------|-------------|---------| -| `search` | string | Search exercises by name or alias | - | -| `letter` | string | Filter by first letter (A-Z) | `All` | -| `tags` | string | Comma-separated tag names | - | -| `tagLogic` | string | Tag combination logic: `AND` or `OR` | `OR` | +| Parameter | Type | Description | Default | +| ---------- | ------ | ------------------------------------ | ------- | +| `search` | string | Search exercises by name or alias | - | +| `letter` | string | Filter by first letter (A-Z) | `All` | +| `tags` | string | Comma-separated tag names | - | +| `tagLogic` | string | Tag combination logic: `AND` or `OR` | `OR` | **Examples:** @@ -50,10 +50,10 @@ The Exicon (`/exicon`) supports the following query parameters: The Lexicon (`/lexicon`) supports the following query parameters: -| Parameter | Type | Description | Default | -|-----------|------|-------------|---------| -| `search` | string | Search terms by name, alias, or description | - | -| `letter` | string | Filter by first letter (A-Z) | `All` | +| Parameter | Type | Description | Default | +| --------- | ------ | ------------------------------------------- | ------- | +| `search` | string | Search terms by name, alias, or description | - | +| `letter` | string | Filter by first letter (A-Z) | `All` | **Examples:** diff --git a/components.json b/components.json index d710b49..7b17557 100644 --- a/components.json +++ b/components.json @@ -18,4 +18,4 @@ "hooks": "@/hooks" }, "iconLibrary": "lucide" -} \ No newline at end of file +} diff --git a/docs/blueprint.md b/docs/blueprint.md index 4f27004..6ced361 100644 --- a/docs/blueprint.md +++ b/docs/blueprint.md @@ -15,4 +15,4 @@ - Accent: A bright blue (#007BFF) for interactive elements and links. - Clean and modern sans-serif fonts for readability and accessibility. - Use consistent and recognizable icons for navigation and actions. -- Responsive layout that adapts to different screen sizes. \ No newline at end of file +- Responsive layout that adapts to different screen sizes. diff --git a/eslint.config.mjs b/eslint.config.mjs index 5194088..fcbac3c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,3 @@ -import nextConfig from 'eslint-config-next'; +import nextConfig from "eslint-config-next"; export default nextConfig; diff --git a/middleware.ts b/middleware.ts index 81494a8..0df406f 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,30 +1,36 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from "next/server"; export function middleware(request: NextRequest) { // Handle CORS for callback routes if ( - request.nextUrl.pathname.startsWith('/callback') || - request.nextUrl.pathname.startsWith('/api/callback') + request.nextUrl.pathname.startsWith("/callback") || + request.nextUrl.pathname.startsWith("/api/callback") ) { // Handle preflight requests - if (request.method === 'OPTIONS') { + if (request.method === "OPTIONS") { return new NextResponse(null, { status: 200, headers: { - 'Access-Control-Allow-Origin': 'https://auth.f3nation.com', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Access-Control-Max-Age': '86400', + "Access-Control-Allow-Origin": "https://auth.f3nation.com", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", }, }); } // Add CORS headers to all callback responses const response = NextResponse.next(); - - response.headers.set('Access-Control-Allow-Origin', 'https://auth.f3nation.com'); - response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + response.headers.set( + "Access-Control-Allow-Origin", + "https://auth.f3nation.com", + ); + response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + response.headers.set( + "Access-Control-Allow-Headers", + "Content-Type, Authorization", + ); return response; } @@ -33,5 +39,5 @@ export function middleware(request: NextRequest) { } export const config = { - matcher: ['/callback/:path*', '/api/callback/:path*'], + matcher: ["/callback/:path*", "/api/callback/:path*"], }; diff --git a/migrations/1747064544503_create-tags-table.cjs b/migrations/1747064544503_create-tags-table.cjs index 3eed53b..8ef592b 100644 --- a/migrations/1747064544503_create-tags-table.cjs +++ b/migrations/1747064544503_create-tags-table.cjs @@ -9,44 +9,50 @@ exports.shorthands = undefined; * @returns {Promise | void} */ exports.up = (pgm) => { - console.log('[MIGRATION_LOG] Starting migration: 1747064544503_create-tags-table.js UP'); - pgm.createTable('tags', { - id: { type: 'text', primaryKey: true }, - name: { type: 'text', notNull: true, unique: true }, + console.log( + "[MIGRATION_LOG] Starting migration: 1747064544503_create-tags-table.js UP", + ); + pgm.createTable("tags", { + id: { type: "text", primaryKey: true }, + name: { type: "text", notNull: true, unique: true }, created_at: { - type: 'timestamp', + type: "timestamp", notNull: true, - default: pgm.func('current_timestamp'), + default: pgm.func("current_timestamp"), }, }); const initialTags = [ - { id: 't1', name: 'Arms' }, - { id: 't2', name: 'Legs' }, - { id: 't3', name: 'Core' }, - { id: 't4', name: 'Cardio' }, - { id: 't5', name: 'Full Body' }, - { id: 't6', name: 'Partner' }, - { id: 't7', name: 'Coupon' }, - { id: 't8', name: 'Music' }, - { id: 't9', name: 'Mosey' }, - { id: 't10', name: 'Static' }, - { id: 't11', name: 'Strength' }, - { id: 't12', name: 'AMRAP' }, - { id: 't13', name: 'EMOM' }, - { id: 't14', name: 'Reps' }, - { id: 't15', name: 'Timed' }, - { id: 't16', name: 'Distance' }, - { id: 't17', name: 'Routine' }, - { id: 't18', name: 'Run' }, - { id: 't19', name: 'Warm-Up' }, - { id: 't20', name: 'Mary' }, + { id: "t1", name: "Arms" }, + { id: "t2", name: "Legs" }, + { id: "t3", name: "Core" }, + { id: "t4", name: "Cardio" }, + { id: "t5", name: "Full Body" }, + { id: "t6", name: "Partner" }, + { id: "t7", name: "Coupon" }, + { id: "t8", name: "Music" }, + { id: "t9", name: "Mosey" }, + { id: "t10", name: "Static" }, + { id: "t11", name: "Strength" }, + { id: "t12", name: "AMRAP" }, + { id: "t13", name: "EMOM" }, + { id: "t14", name: "Reps" }, + { id: "t15", name: "Timed" }, + { id: "t16", name: "Distance" }, + { id: "t17", name: "Routine" }, + { id: "t18", name: "Run" }, + { id: "t19", name: "Warm-Up" }, + { id: "t20", name: "Mary" }, ]; for (const tag of initialTags) { - pgm.sql(`INSERT INTO tags (id, name) VALUES ('${tag.id}', '${tag.name.replace(/'/g, "''")}');`); + pgm.sql( + `INSERT INTO tags (id, name) VALUES ('${tag.id}', '${tag.name.replace(/'/g, "''")}');`, + ); } - console.log('[MIGRATION_LOG] Finished migration: 1747064544503_create-tags-table.js UP'); + console.log( + "[MIGRATION_LOG] Finished migration: 1747064544503_create-tags-table.js UP", + ); }; /** @@ -55,7 +61,11 @@ exports.up = (pgm) => { * @returns {Promise | void} */ exports.down = (pgm) => { - console.log('[MIGRATION_LOG] Starting migration: 1747064544503_create-tags-table.js DOWN'); - pgm.dropTable('tags'); - console.log('[MIGRATION_LOG] Finished migration: 1747064544503_create-tags-table.js DOWN'); + console.log( + "[MIGRATION_LOG] Starting migration: 1747064544503_create-tags-table.js DOWN", + ); + pgm.dropTable("tags"); + console.log( + "[MIGRATION_LOG] Finished migration: 1747064544503_create-tags-table.js DOWN", + ); }; diff --git a/migrations/1747064552223_create-user-submissions-table.cjs b/migrations/1747064552223_create-user-submissions-table.cjs index 5c6fc40..ee60648 100644 --- a/migrations/1747064552223_create-user-submissions-table.cjs +++ b/migrations/1747064552223_create-user-submissions-table.cjs @@ -9,7 +9,7 @@ exports.shorthands = undefined; */ exports.up = (pgm) => { console.log( - "[MIGRATION_LOG] Starting migration: create-user-submissions-table UP" + "[MIGRATION_LOG] Starting migration: create-user-submissions-table UP", ); pgm.createTable("user_submissions", { @@ -59,7 +59,7 @@ exports.up = (pgm) => { `); console.log( - "[MIGRATION_LOG] Finished migration: create-user-submissions-table UP" + "[MIGRATION_LOG] Finished migration: create-user-submissions-table UP", ); }; @@ -69,17 +69,17 @@ exports.up = (pgm) => { */ exports.down = (pgm) => { console.log( - "[MIGRATION_LOG] Starting migration: create-user-submissions-table DOWN" + "[MIGRATION_LOG] Starting migration: create-user-submissions-table DOWN", ); pgm.sql( - `DROP TRIGGER IF EXISTS trigger_user_submissions_updated_at ON user_submissions;` + `DROP TRIGGER IF EXISTS trigger_user_submissions_updated_at ON user_submissions;`, ); pgm.sql(`DROP FUNCTION IF EXISTS update_updated_at_column;`); pgm.dropTable("user_submissions"); console.log( - "[MIGRATION_LOG] Finished migration: create-user-submissions-table DOWN" + "[MIGRATION_LOG] Finished migration: create-user-submissions-table DOWN", ); }; diff --git a/migrations/1752677590715_create-entries-and-entry-tags-table.cjs b/migrations/1752677590715_create-entries-and-entry-tags-table.cjs index 25d303a..d9000d3 100644 --- a/migrations/1752677590715_create-entries-and-entry-tags-table.cjs +++ b/migrations/1752677590715_create-entries-and-entry-tags-table.cjs @@ -10,7 +10,7 @@ exports.shorthands = undefined; */ exports.up = (pgm) => { console.log( - "[MIGRATION_LOG] Starting migration: create_entries_and_entry_tags_table UP" + "[MIGRATION_LOG] Starting migration: create_entries_and_entry_tags_table UP", ); // Create 'entries' table (this part remains as is, as it's working) @@ -36,7 +36,7 @@ exports.up = (pgm) => { }, { ifNotExists: true, - } + }, ); pgm.createIndex("entries", "title", { ifNotExists: true }); @@ -58,7 +58,7 @@ exports.up = (pgm) => { // This migration file now ONLY defines the database schema. console.log( - "[MIGRATION_LOG] Finished migration: create_entries_and_entry_tags_table UP" + "[MIGRATION_LOG] Finished migration: create_entries_and_entry_tags_table UP", ); }; @@ -69,7 +69,7 @@ exports.up = (pgm) => { */ exports.down = (pgm) => { console.log( - "[MIGRATION_LOG] Starting migration: create_entries_and_entry_tags_table DOWN" + "[MIGRATION_LOG] Starting migration: create_entries_and_entry_tags_table DOWN", ); // When using pgm.sql for UP, you should use pgm.sql for DOWN too for consistency, // or ensure pgm.dropTable can correctly identify the table created by pgm.sql. @@ -80,6 +80,6 @@ exports.down = (pgm) => { pgm.dropIndex("entries", "type", { ifExists: true }); pgm.dropIndex("entries", "title", { ifExists: true }); console.log( - "[MIGRATION_LOG] Finished migration: create_entries_and_entry_tags_table DOWN" + "[MIGRATION_LOG] Finished migration: create_entries_and_entry_tags_table DOWN", ); }; diff --git a/migrations/1752732000000_add-mentioned-entries-column.cjs b/migrations/1752732000000_add-mentioned-entries-column.cjs index f30d5aa..74029d5 100644 --- a/migrations/1752732000000_add-mentioned-entries-column.cjs +++ b/migrations/1752732000000_add-mentioned-entries-column.cjs @@ -10,7 +10,7 @@ exports.shorthands = undefined; */ exports.up = (pgm) => { console.log( - "[MIGRATION_LOG] Starting migration: add_mentioned_entries_column_to_entries UP" + "[MIGRATION_LOG] Starting migration: add_mentioned_entries_column_to_entries UP", ); pgm.sql(` @@ -30,7 +30,7 @@ exports.up = (pgm) => { `); console.log( - "[MIGRATION_LOG] Finished migration: add_mentioned_entries_column_to_entries UP" + "[MIGRATION_LOG] Finished migration: add_mentioned_entries_column_to_entries UP", ); }; @@ -41,7 +41,7 @@ exports.up = (pgm) => { */ exports.down = (pgm) => { console.log( - "[MIGRATION_LOG] Starting migration: add_mentioned_entries_column_to_entries DOWN" + "[MIGRATION_LOG] Starting migration: add_mentioned_entries_column_to_entries DOWN", ); pgm.sql(` @@ -50,6 +50,6 @@ exports.down = (pgm) => { `); console.log( - "[MIGRATION_LOG] Finished migration: add_mentioned_entries_column_to_entries DOWN" + "[MIGRATION_LOG] Finished migration: add_mentioned_entries_column_to_entries DOWN", ); }; diff --git a/migrations/1752732100000_create-entry-references-table.cjs b/migrations/1752732100000_create-entry-references-table.cjs index 94dce27..cbc2d6c 100644 --- a/migrations/1752732100000_create-entry-references-table.cjs +++ b/migrations/1752732100000_create-entry-references-table.cjs @@ -10,7 +10,7 @@ exports.shorthands = undefined; */ exports.up = (pgm) => { console.log( - "[MIGRATION_LOG] Starting migration: create_entry_references_table UP" + "[MIGRATION_LOG] Starting migration: create_entry_references_table UP", ); pgm.createTable( @@ -45,12 +45,16 @@ exports.up = (pgm) => { }, { ifNotExists: true, - } + }, ); - pgm.addConstraint("entry_references", "entry_references_unique_source_target", { - unique: ["source_entry_id", "target_entry_id"], - }); + pgm.addConstraint( + "entry_references", + "entry_references_unique_source_target", + { + unique: ["source_entry_id", "target_entry_id"], + }, + ); pgm.createIndex("entry_references", ["source_entry_id"], { ifNotExists: true, @@ -61,7 +65,7 @@ exports.up = (pgm) => { }); console.log( - "[MIGRATION_LOG] Finished migration: create_entry_references_table UP" + "[MIGRATION_LOG] Finished migration: create_entry_references_table UP", ); }; @@ -72,17 +76,21 @@ exports.up = (pgm) => { */ exports.down = (pgm) => { console.log( - "[MIGRATION_LOG] Starting migration: create_entry_references_table DOWN" + "[MIGRATION_LOG] Starting migration: create_entry_references_table DOWN", ); pgm.dropIndex("entry_references", ["target_entry_id"], { ifExists: true }); pgm.dropIndex("entry_references", ["source_entry_id"], { ifExists: true }); - pgm.dropConstraint("entry_references", "entry_references_unique_source_target", { - ifExists: true, - }); + pgm.dropConstraint( + "entry_references", + "entry_references_unique_source_target", + { + ifExists: true, + }, + ); pgm.dropTable("entry_references", { ifExists: true }); console.log( - "[MIGRATION_LOG] Finished migration: create_entry_references_table DOWN" + "[MIGRATION_LOG] Finished migration: create_entry_references_table DOWN", ); }; diff --git a/migrations/1762262413053_add-performance-indexes.cjs b/migrations/1762262413053_add-performance-indexes.cjs index 3f5b33e..f34c02b 100644 --- a/migrations/1762262413053_add-performance-indexes.cjs +++ b/migrations/1762262413053_add-performance-indexes.cjs @@ -9,9 +9,7 @@ exports.shorthands = undefined; * @returns {Promise | void} */ exports.up = (pgm) => { - console.log( - "[MIGRATION_LOG] Starting migration: add_performance_indexes UP" - ); + console.log("[MIGRATION_LOG] Starting migration: add_performance_indexes UP"); // Add index on target_entry_id for entry_references table (in codex schema) // This optimizes queries that join on target_entry_id (e.g., finding entries that reference a given entry) @@ -49,9 +47,7 @@ exports.up = (pgm) => { END $$; `); - console.log( - "[MIGRATION_LOG] Finished migration: add_performance_indexes UP" - ); + console.log("[MIGRATION_LOG] Finished migration: add_performance_indexes UP"); }; /** @@ -61,7 +57,7 @@ exports.up = (pgm) => { */ exports.down = (pgm) => { console.log( - "[MIGRATION_LOG] Starting migration: add_performance_indexes DOWN" + "[MIGRATION_LOG] Starting migration: add_performance_indexes DOWN", ); // Drop indexes from both schemas @@ -76,6 +72,6 @@ exports.down = (pgm) => { `); console.log( - "[MIGRATION_LOG] Finished migration: add_performance_indexes DOWN" + "[MIGRATION_LOG] Finished migration: add_performance_indexes DOWN", ); }; diff --git a/next.config.ts b/next.config.ts index 610cde3..695141f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,49 +1,49 @@ -import type { NextConfig } from 'next'; +import type { NextConfig } from "next"; const nextConfig: NextConfig = { images: { remotePatterns: [ { - protocol: 'https', - hostname: 'picsum.photos', - port: '', - pathname: '/**', + protocol: "https", + hostname: "picsum.photos", + port: "", + pathname: "/**", }, ], }, async headers() { return [ { - source: '/callback/:path*', + source: "/callback/:path*", headers: [ { - key: 'Access-Control-Allow-Origin', - value: 'https://auth.f3nation.com', + key: "Access-Control-Allow-Origin", + value: "https://auth.f3nation.com", }, { - key: 'Access-Control-Allow-Methods', - value: 'GET, POST, OPTIONS', + key: "Access-Control-Allow-Methods", + value: "GET, POST, OPTIONS", }, { - key: 'Access-Control-Allow-Headers', - value: 'Content-Type, Authorization', + key: "Access-Control-Allow-Headers", + value: "Content-Type, Authorization", }, ], }, { - source: '/api/callback/:path*', + source: "/api/callback/:path*", headers: [ { - key: 'Access-Control-Allow-Origin', - value: 'https://auth.f3nation.com', + key: "Access-Control-Allow-Origin", + value: "https://auth.f3nation.com", }, { - key: 'Access-Control-Allow-Methods', - value: 'GET, POST, OPTIONS', + key: "Access-Control-Allow-Methods", + value: "GET, POST, OPTIONS", }, { - key: 'Access-Control-Allow-Headers', - value: 'Content-Type, Authorization', + key: "Access-Control-Allow-Headers", + value: "Content-Type, Authorization", }, ], }, diff --git a/public/404.html b/public/404.html index 829eda8..e76cee4 100644 --- a/public/404.html +++ b/public/404.html @@ -1,23 +1,79 @@ - + - - + + Page Not Found @@ -25,9 +81,16 @@

404

Page Not Found

-

The specified file was not found on this website. Please check the URL for mistakes and try again.

+

+ The specified file was not found on this website. Please check the URL + for mistakes and try again. +

Why am I seeing this?

-

This page was generated by the Firebase Command-Line Interface. To modify it, edit the 404.html file in your project's configured public directory.

+

+ This page was generated by the Firebase Command-Line Interface. To + modify it, edit the 404.html file in your project's + configured public directory. +

diff --git a/scripts/db-generate-migration.ts b/scripts/db-generate-migration.ts index 2314712..57aa491 100644 --- a/scripts/db-generate-migration.ts +++ b/scripts/db-generate-migration.ts @@ -1,7 +1,7 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import process from 'node:process'; -import { spawn } from 'node:child_process'; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { spawn } from "node:child_process"; function ensureMigrationsDir(dir: string): void { if (!fs.existsSync(dir)) { @@ -10,17 +10,29 @@ function ensureMigrationsDir(dir: string): void { } function resolveCliPath(): string { - const cliPath = path.resolve(process.cwd(), 'node_modules', 'node-pg-migrate', 'bin', 'node-pg-migrate.js'); + const cliPath = path.resolve( + process.cwd(), + "node_modules", + "node-pg-migrate", + "bin", + "node-pg-migrate.js", + ); if (!fs.existsSync(cliPath)) { - throw new Error('Unable to locate node-pg-migrate CLI. Did you install dependencies?'); + throw new Error( + "Unable to locate node-pg-migrate CLI. Did you install dependencies?", + ); } return cliPath; } -function buildArguments(name: string, additionalArgs: string[], migrationsDir: string): string[] { - return ['create', name, '--migrations-dir', migrationsDir, ...additionalArgs]; +function buildArguments( + name: string, + additionalArgs: string[], + migrationsDir: string, +): string[] { + return ["create", name, "--migrations-dir", migrationsDir, ...additionalArgs]; } function main(): void { @@ -28,30 +40,32 @@ function main(): void { const migrationName = args.shift(); if (!migrationName) { - console.error('Usage: npm run db:generate:migration -- [node-pg-migrate options]'); + console.error( + "Usage: npm run db:generate:migration -- [node-pg-migrate options]", + ); process.exit(1); } - const migrationsDir = path.resolve(process.cwd(), 'migrations'); + const migrationsDir = path.resolve(process.cwd(), "migrations"); ensureMigrationsDir(migrationsDir); const cliPath = resolveCliPath(); const cliArgs = buildArguments(migrationName, args, migrationsDir); const child = spawn(process.execPath, [cliPath, ...cliArgs], { - stdio: 'inherit', + stdio: "inherit", env: process.env, }); - child.on('exit', (code) => { + child.on("exit", (code) => { if (code === 0) { console.info(`Created migration "${migrationName}" in ${migrationsDir}.`); } process.exit(code ?? 1); }); - child.on('error', (error) => { - console.error('Failed to spawn node-pg-migrate CLI.'); + child.on("error", (error) => { + console.error("Failed to spawn node-pg-migrate CLI."); console.error(error); process.exit(1); }); diff --git a/scripts/db-migrate.ts b/scripts/db-migrate.ts index 86dca98..c11976c 100644 --- a/scripts/db-migrate.ts +++ b/scripts/db-migrate.ts @@ -1,12 +1,12 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import process from 'node:process'; -import dotenv from 'dotenv'; -import runner from 'node-pg-migrate'; -import type { Logger } from 'node-pg-migrate/dist/types'; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import dotenv from "dotenv"; +import runner from "node-pg-migrate"; +import type { Logger } from "node-pg-migrate/dist/types"; -type MigrationCommand = 'up' | 'down' | 'redo'; +type MigrationCommand = "up" | "down" | "redo"; interface CliOptions { count?: number; @@ -17,7 +17,7 @@ interface CliOptions { } function loadEnvFiles(): void { - const envFiles = ['.env', '.env.local']; + const envFiles = [".env", ".env.local"]; for (const fileName of envFiles) { const absolutePath = path.resolve(process.cwd(), fileName); @@ -27,16 +27,21 @@ function loadEnvFiles(): void { } } -function parseArgs(argv: string[]): { command: MigrationCommand; options: CliOptions } { +function parseArgs(argv: string[]): { + command: MigrationCommand; + options: CliOptions; +} { const args = [...argv]; - let command: MigrationCommand = 'up'; + let command: MigrationCommand = "up"; - if (args[0] && !args[0].startsWith('--')) { + if (args[0] && !args[0].startsWith("--")) { const candidate = args.shift()!; - if (candidate === 'up' || candidate === 'down' || candidate === 'redo') { + if (candidate === "up" || candidate === "down" || candidate === "redo") { command = candidate; } else { - throw new Error(`Unknown migration command "${candidate}". Use "up", "down", or "redo".`); + throw new Error( + `Unknown migration command "${candidate}". Use "up", "down", or "redo".`, + ); } } @@ -45,27 +50,27 @@ function parseArgs(argv: string[]): { command: MigrationCommand; options: CliOpt while (args.length > 0) { const token = args.shift()!; - if (token === '--count') { + if (token === "--count") { const value = args.shift(); if (!value) { throw new Error('The "--count" option requires a numeric value.'); } options.count = parseCount(value); - } else if (token.startsWith('--count=')) { - options.count = parseCount(token.split('=')[1] ?? ''); - } else if (token === '--file') { + } else if (token.startsWith("--count=")) { + options.count = parseCount(token.split("=")[1] ?? ""); + } else if (token === "--file") { const value = args.shift(); if (!value) { throw new Error('The "--file" option requires a migration filename.'); } options.file = value; - } else if (token.startsWith('--file=')) { - options.file = token.split('=')[1]; - } else if (token === '--dry-run') { + } else if (token.startsWith("--file=")) { + options.file = token.split("=")[1]; + } else if (token === "--dry-run") { options.dryRun = true; - } else if (token === '--fake') { + } else if (token === "--fake") { options.fake = true; - } else if (token === '--verbose') { + } else if (token === "--verbose") { options.verbose = true; } else { throw new Error(`Unknown option "${token}".`); @@ -78,7 +83,9 @@ function parseArgs(argv: string[]): { command: MigrationCommand; options: CliOpt function parseCount(raw: string): number { const parsed = Number.parseInt(raw, 10); if (Number.isNaN(parsed) || parsed < 1) { - throw new Error(`Invalid count "${raw}". Count must be a positive integer.`); + throw new Error( + `Invalid count "${raw}". Count must be a positive integer.`, + ); } return parsed; } @@ -98,11 +105,11 @@ interface PreparedMigrationsDir { } const MIGRATION_PRIORITY: Record = { - '.ts': 0, - '.cjs': 1, - '.mjs': 2, - '.js': 3, - '.sql': 4, + ".ts": 0, + ".cjs": 1, + ".mjs": 2, + ".js": 3, + ".sql": 4, }; function isSupportedMigrationFile(fileName: string): boolean { @@ -114,7 +121,10 @@ function selectMigrationFiles( migrationsDir: string, ): { fileName: string; absolutePath: string; priority: number }[] { const entries = fs.readdirSync(migrationsDir, { withFileTypes: true }); - const selections = new Map(); + const selections = new Map< + string, + { fileName: string; absolutePath: string; priority: number } + >(); for (const entry of entries) { if (!entry.isFile()) continue; @@ -134,25 +144,37 @@ function selectMigrationFiles( } } - return Array.from(selections.values()).sort((a, b) => a.fileName.localeCompare(b.fileName)); + return Array.from(selections.values()).sort((a, b) => + a.fileName.localeCompare(b.fileName), + ); } -function prepareMigrationsDirectory(migrationsDir: string, verbose?: boolean): PreparedMigrationsDir { +function prepareMigrationsDirectory( + migrationsDir: string, + verbose?: boolean, +): PreparedMigrationsDir { const selectedFiles = selectMigrationFiles(migrationsDir); - const duplicatesFiltered = selectedFiles.length !== fs.readdirSync(migrationsDir).filter((entry) => { - const ext = path.extname(entry); - return isSupportedMigrationFile(entry) && fs.statSync(path.join(migrationsDir, entry)).isFile(); - }).length; - - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-migrations-')); + const duplicatesFiltered = + selectedFiles.length !== + fs.readdirSync(migrationsDir).filter((entry) => { + const ext = path.extname(entry); + return ( + isSupportedMigrationFile(entry) && + fs.statSync(path.join(migrationsDir, entry)).isFile() + ); + }).length; + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-migrations-")); for (const file of selectedFiles) { const destination = path.join(tempDir, file.fileName); fs.copyFileSync(file.absolutePath, destination); } if (verbose && duplicatesFiltered) { - console.info('[db:migrate] Filtered duplicate migration files by basename.'); + console.info( + "[db:migrate] Filtered duplicate migration files by basename.", + ); } return { @@ -164,22 +186,25 @@ function prepareMigrationsDirectory(migrationsDir: string, verbose?: boolean): P } async function runMigrations( - direction: 'up' | 'down', + direction: "up" | "down", options: CliOptions, databaseUrl: string, migrationsDir: string, ): Promise { const logger = createLogger(options.verbose); - const preparedDir = prepareMigrationsDirectory(migrationsDir, options.verbose); + const preparedDir = prepareMigrationsDirectory( + migrationsDir, + options.verbose, + ); let executed; try { executed = await runner({ databaseUrl, dir: preparedDir.dir, - migrationsTable: 'pgmigrations', + migrationsTable: "pgmigrations", direction, - count: options.count ?? (direction === 'down' ? 1 : undefined), + count: options.count ?? (direction === "down" ? 1 : undefined), file: options.file, dryRun: options.dryRun, fake: options.fake, @@ -191,7 +216,9 @@ async function runMigrations( } if (options.dryRun) { - console.info(`[db:migrate ${direction}] Dry run complete. ${executed.length} migration(s) would run.`); + console.info( + `[db:migrate ${direction}] Dry run complete. ${executed.length} migration(s) would run.`, + ); return; } @@ -203,7 +230,7 @@ async function runMigrations( console.info( `[db:migrate ${direction}] Executed ${executed.length} migration(s): ${executed .map((migration) => migration.name) - .join(', ')}`, + .join(", ")}`, ); } @@ -214,17 +241,24 @@ async function main(): Promise { const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { - throw new Error('DATABASE_URL is not set. Please ensure your environment is configured before running migrations.'); + throw new Error( + "DATABASE_URL is not set. Please ensure your environment is configured before running migrations.", + ); } - const migrationsDir = path.resolve(process.cwd(), 'migrations'); + const migrationsDir = path.resolve(process.cwd(), "migrations"); if (!fs.existsSync(migrationsDir)) { throw new Error(`Migrations directory not found at ${migrationsDir}.`); } - if (command === 'redo') { - await runMigrations('down', { ...options, count: options.count ?? 1 }, databaseUrl, migrationsDir); - await runMigrations('up', { ...options }, databaseUrl, migrationsDir); + if (command === "redo") { + await runMigrations( + "down", + { ...options, count: options.count ?? 1 }, + databaseUrl, + migrationsDir, + ); + await runMigrations("up", { ...options }, databaseUrl, migrationsDir); return; } @@ -232,7 +266,7 @@ async function main(): Promise { } main().catch((error) => { - console.error('[db:migrate] Migration command failed.'); + console.error("[db:migrate] Migration command failed."); console.error(error instanceof Error ? error.message : error); process.exit(1); }); diff --git a/src/ai/dev.ts b/src/ai/dev.ts index 0d65069..29462db 100644 --- a/src/ai/dev.ts +++ b/src/ai/dev.ts @@ -1,4 +1,4 @@ -import { config } from 'dotenv'; +import { config } from "dotenv"; config(); -import '@/ai/flows/auto-link-references.ts'; \ No newline at end of file +import "@/ai/flows/auto-link-references.ts"; diff --git a/src/ai/flows/auto-link-references.ts b/src/ai/flows/auto-link-references.ts index be6b942..15655e1 100644 --- a/src/ai/flows/auto-link-references.ts +++ b/src/ai/flows/auto-link-references.ts @@ -1,5 +1,5 @@ // src/ai/flows/auto-link-references.ts -'use server'; +"use server"; /** * @fileOverview Automatically suggests and creates links to other entries within a given text. * @@ -8,28 +8,36 @@ * - AutoLinkReferencesOutput - The return type for the autoLinkReferences function. */ -import {ai} from '@/ai/genkit'; -import {z} from 'genkit'; +import { ai } from "@/ai/genkit"; +import { z } from "genkit"; const AutoLinkReferencesInputSchema = z.object({ - text: z.string().describe('The text to scan for F3 terms to link.'), - allEntryNames: z.array(z.string()).describe('An array of all known F3 entry names.'), + text: z.string().describe("The text to scan for F3 terms to link."), + allEntryNames: z + .array(z.string()) + .describe("An array of all known F3 entry names."), }); -export type AutoLinkReferencesInput = z.infer; +export type AutoLinkReferencesInput = z.infer< + typeof AutoLinkReferencesInputSchema +>; const AutoLinkReferencesOutputSchema = z.object({ - linkedText: z.string().describe('The text with F3 terms linked.'), + linkedText: z.string().describe("The text with F3 terms linked."), }); -export type AutoLinkReferencesOutput = z.infer; +export type AutoLinkReferencesOutput = z.infer< + typeof AutoLinkReferencesOutputSchema +>; -export async function autoLinkReferences(input: AutoLinkReferencesInput): Promise { +export async function autoLinkReferences( + input: AutoLinkReferencesInput, +): Promise { return autoLinkReferencesFlow(input); } const autoLinkReferencesPrompt = ai.definePrompt({ - name: 'autoLinkReferencesPrompt', - input: {schema: AutoLinkReferencesInputSchema}, - output: {schema: AutoLinkReferencesOutputSchema}, + name: "autoLinkReferencesPrompt", + input: { schema: AutoLinkReferencesInputSchema }, + output: { schema: AutoLinkReferencesOutputSchema }, prompt: `You are an AI assistant that helps enhance text by identifying and linking F3 terms within it. Given the following text and a list of all known F3 entry names, identify which F3 terms are present in the text. @@ -43,17 +51,17 @@ const autoLinkReferencesPrompt = ai.definePrompt({ Do not create links for terms that are not in the All Entry Names list. Do not modify any existing links in the provided text. If no terms are found in the text, simply return the original text. - `, + `, }); const autoLinkReferencesFlow = ai.defineFlow( { - name: 'autoLinkReferencesFlow', + name: "autoLinkReferencesFlow", inputSchema: AutoLinkReferencesInputSchema, outputSchema: AutoLinkReferencesOutputSchema, }, - async input => { - const {output} = await autoLinkReferencesPrompt(input); + async (input) => { + const { output } = await autoLinkReferencesPrompt(input); return output!; - } + }, ); diff --git a/src/ai/genkit.ts b/src/ai/genkit.ts index cbf2594..32358f4 100644 --- a/src/ai/genkit.ts +++ b/src/ai/genkit.ts @@ -1,7 +1,7 @@ -import {genkit} from 'genkit'; -import {googleAI} from '@genkit-ai/googleai'; +import { genkit } from "genkit"; +import { googleAI } from "@genkit-ai/googleai"; export const ai = genkit({ plugins: [googleAI()], - model: 'googleai/gemini-2.0-flash', + model: "googleai/gemini-2.0-flash", }); diff --git a/src/app/actions.ts b/src/app/actions.ts index bf7f373..0e12424 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -1,8 +1,11 @@ // src/app/actions.ts -'use server'; +"use server"; -import { autoLinkReferences, type AutoLinkReferencesInput } from '@/ai/flows/auto-link-references'; -import { getAllEntryNamesFromDatabase } from '@/lib/api'; // Import new DB function +import { + autoLinkReferences, + type AutoLinkReferencesInput, +} from "@/ai/flows/auto-link-references"; +import { getAllEntryNamesFromDatabase } from "@/lib/api"; // Import new DB function export async function getLinkedText(text: string): Promise { // --- BEGIN TEMPORARY FIX for "429 Too Many Requests" --- diff --git a/src/app/admin/AdminPanel.tsx b/src/app/admin/AdminPanel.tsx index 7100719..38f63e1 100644 --- a/src/app/admin/AdminPanel.tsx +++ b/src/app/admin/AdminPanel.tsx @@ -1,1059 +1,1531 @@ -'use client'; +"use client"; -import { useState, useEffect, useCallback } from 'react'; -import { useRouter } from 'next/navigation'; -import { PageContainer } from '@/components/layout/PageContainer'; -import { Button } from '@/components/ui/button'; -import { EntryForm } from '@/components/admin/EntryForm'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { PlusCircle, Edit, Trash2, ShieldCheck, Inbox, Tag as TagIcon, CheckCircle, XCircle, Eye, LogOut, Flame, Lock, Pencil, Save, X } from 'lucide-react'; -import type { AnyEntry, Tag, NewEntrySuggestionData, EditEntrySuggestionData, ExiconEntry, UserSubmissionBase, Alias } from '@/lib/types'; +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { PageContainer } from "@/components/layout/PageContainer"; +import { Button } from "@/components/ui/button"; +import { EntryForm } from "@/components/admin/EntryForm"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogClose, - DialogFooter, - DialogDescription, -} from "@/components/ui/dialog" -import { useToast } from '@/hooks/use-toast'; -import { Badge } from '@/components/ui/badge' -import { Textarea } from '@/components/ui/textarea'; -import { Separator } from '@/components/ui/separator'; -import { Checkbox } from '@/components/ui/checkbox'; + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { - createEntryInDatabase, - updateEntryInDatabase, - deleteEntryFromDatabase, - fetchTagsFromDatabase, - createTagInDatabase, - updateTagInDatabase, - deleteTagFromDatabase, - fetchPendingSubmissionsFromDatabase, - updateSubmissionStatusInDatabase, - applyApprovedSubmissionToDatabase, - fetchEntryById, - fetchAllEntries, - verifyAdminEmail, -} from './actions'; -import { getOAuthConfig } from '@/lib/auth'; + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + PlusCircle, + Edit, + Trash2, + ShieldCheck, + Inbox, + Tag as TagIcon, + CheckCircle, + XCircle, + Eye, + LogOut, + Flame, + Lock, + Pencil, + Save, + X, +} from "lucide-react"; +import type { + AnyEntry, + Tag, + NewEntrySuggestionData, + EditEntrySuggestionData, + ExiconEntry, + UserSubmissionBase, + Alias, +} from "@/lib/types"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + createEntryInDatabase, + updateEntryInDatabase, + deleteEntryFromDatabase, + fetchTagsFromDatabase, + createTagInDatabase, + updateTagInDatabase, + deleteTagFromDatabase, + fetchPendingSubmissionsFromDatabase, + updateSubmissionStatusInDatabase, + applyApprovedSubmissionToDatabase, + fetchEntryById, + fetchAllEntries, + verifyAdminEmail, +} from "./actions"; +import { getOAuthConfig } from "@/lib/auth"; interface UserInfo { - sub: string; - name?: string; - email?: string; - picture?: string; - email_verified?: boolean; + sub: string; + name?: string; + email?: string; + picture?: string; + email_verified?: boolean; } interface OAuthConfig { - CLIENT_ID: string; - REDIRECT_URI: string; - AUTH_SERVER_URL: string; + CLIENT_ID: string; + REDIRECT_URI: string; + AUTH_SERVER_URL: string; } export default function AdminPanel() { - const { toast } = useToast(); - const router = useRouter(); - - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [userInfo, setUserInfo] = useState(null); - const [oauthConfig, setOauthConfig] = useState(null); - - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const [isEntryFormOpen, setIsEntryFormOpen] = useState(false); - const [editingEntry, setEditingEntry] = useState(undefined); - const [isSubmitting, setIsSubmitting] = useState(false); - - const [tags, setTags] = useState([]); - const [isTagFormOpen, setIsTagFormOpen] = useState(false); - const [editingTag, setEditingTag] = useState(undefined); - const [newTagName, setNewTagName] = useState(''); - - const [userSubmissions, setUserSubmissions] = useState[]>([]); - const [viewingSubmission, setViewingSubmission] = useState | undefined>(undefined); - const [editedSubmissionData, setEditedSubmissionData] = useState(undefined); - const [isEditingSubmission, setIsEditingSubmission] = useState(false); - const [originalEntryForEditView, setOriginalEntryForEditView] = useState(null); - const [isLoadingOriginalEntry, setIsLoadingOriginalEntry] = useState(false); - const [isSubmissionDetailOpen, setIsSubmissionDetailOpen] = useState(false); - - const [lexiconEntriesForDisplay, setLexiconEntriesForDisplay] = useState([]); - const [isLoadingEntries, setIsLoadingEntries] = useState(true); - - const [searchTerm, setSearchTerm] = useState(''); - const [filterLetter, setFilterLetter] = useState('All'); - const [filteredEntries, setFilteredEntries] = useState([]); - const [currentPage, setCurrentPage] = useState(1); - const [entriesPerPage, setEntriesPerPage] = useState(20); - - // Authentication handlers - const handleLogin = () => { - if (!oauthConfig) { - setError('OAuth configuration not loaded'); - return; - } - - // Generate CSRF token and create state parameter in the format expected by auth-provider - const csrfToken = crypto.randomUUID(); - const stateData = { - csrfToken, - clientId: oauthConfig.CLIENT_ID, - returnTo: oauthConfig.REDIRECT_URI, - timestamp: Date.now(), - }; - - // Encode state as base64-encoded JSON (matching auth-provider's expectation) - const state = btoa(JSON.stringify(stateData)); - localStorage.setItem('oauth_state', state); + const { toast } = useToast(); + const router = useRouter(); + + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [userInfo, setUserInfo] = useState(null); + const [oauthConfig, setOauthConfig] = useState(null); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [isEntryFormOpen, setIsEntryFormOpen] = useState(false); + const [editingEntry, setEditingEntry] = useState( + undefined, + ); + const [isSubmitting, setIsSubmitting] = useState(false); + + const [tags, setTags] = useState([]); + const [isTagFormOpen, setIsTagFormOpen] = useState(false); + const [editingTag, setEditingTag] = useState(undefined); + const [newTagName, setNewTagName] = useState(""); + + const [userSubmissions, setUserSubmissions] = useState< + UserSubmissionBase[] + >([]); + const [viewingSubmission, setViewingSubmission] = useState< + UserSubmissionBase | undefined + >(undefined); + const [editedSubmissionData, setEditedSubmissionData] = useState< + NewEntrySuggestionData | EditEntrySuggestionData | undefined + >(undefined); + const [isEditingSubmission, setIsEditingSubmission] = useState(false); + const [originalEntryForEditView, setOriginalEntryForEditView] = + useState(null); + const [isLoadingOriginalEntry, setIsLoadingOriginalEntry] = useState(false); + const [isSubmissionDetailOpen, setIsSubmissionDetailOpen] = useState(false); + + const [lexiconEntriesForDisplay, setLexiconEntriesForDisplay] = useState< + AnyEntry[] + >([]); + const [isLoadingEntries, setIsLoadingEntries] = useState(true); + + const [searchTerm, setSearchTerm] = useState(""); + const [filterLetter, setFilterLetter] = useState("All"); + const [filteredEntries, setFilteredEntries] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const [entriesPerPage, setEntriesPerPage] = useState(20); + + // Authentication handlers + const handleLogin = () => { + if (!oauthConfig) { + setError("OAuth configuration not loaded"); + return; + } - window.location.href = `${oauthConfig.AUTH_SERVER_URL}/api/oauth/authorize?response_type=code&client_id=${oauthConfig.CLIENT_ID}&redirect_uri=${encodeURIComponent(oauthConfig.REDIRECT_URI)}&scope=openid%20profile%20email&state=${encodeURIComponent(state)}`; + // Generate CSRF token and create state parameter in the format expected by auth-provider + const csrfToken = crypto.randomUUID(); + const stateData = { + csrfToken, + clientId: oauthConfig.CLIENT_ID, + returnTo: oauthConfig.REDIRECT_URI, + timestamp: Date.now(), }; - const handleLogout = useCallback(() => { - // Clear all stored auth data - localStorage.removeItem('user_info'); - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); - localStorage.removeItem('oauth_state'); - - setUserInfo(null); - setIsAuthenticated(false); - setError(null); - }, []); - - // Initialize OAuth configuration and check for stored user info - useEffect(() => { - const initializeApp = async () => { - try { - // Load OAuth configuration using server action - const config = await getOAuthConfig(); - setOauthConfig(config); - - // Check for stored user info - const storedUserInfo = localStorage.getItem('user_info'); - if (storedUserInfo) { - try { - const parsedUserInfo: UserInfo = JSON.parse(storedUserInfo); - if (parsedUserInfo?.email) { - try { - const hasAccess = await verifyAdminEmail(parsedUserInfo.email); - if (hasAccess) { - setUserInfo(parsedUserInfo); - setIsAuthenticated(true); - setError(null); - } else { - handleLogout(); - setError('You do not have admin access. Please contact the Codex team if you believe this is a mistake.'); - } - } catch (verificationError) { - console.error('Failed to verify admin access:', verificationError); - setIsAuthenticated(false); - setError('Failed to verify admin access. Please try again later.'); - } - } else { - handleLogout(); - setError('Unable to determine the authenticated email address. Please log in again.'); - } - } catch (parseError) { - console.error('Failed to parse stored user info:', parseError); - localStorage.removeItem('user_info'); - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); - } + // Encode state as base64-encoded JSON (matching auth-provider's expectation) + const state = btoa(JSON.stringify(stateData)); + localStorage.setItem("oauth_state", state); + + window.location.href = `${oauthConfig.AUTH_SERVER_URL}/api/oauth/authorize?response_type=code&client_id=${oauthConfig.CLIENT_ID}&redirect_uri=${encodeURIComponent(oauthConfig.REDIRECT_URI)}&scope=openid%20profile%20email&state=${encodeURIComponent(state)}`; + }; + + const handleLogout = useCallback(() => { + // Clear all stored auth data + localStorage.removeItem("user_info"); + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + localStorage.removeItem("oauth_state"); + + setUserInfo(null); + setIsAuthenticated(false); + setError(null); + }, []); + + // Initialize OAuth configuration and check for stored user info + useEffect(() => { + const initializeApp = async () => { + try { + // Load OAuth configuration using server action + const config = await getOAuthConfig(); + setOauthConfig(config); + + // Check for stored user info + const storedUserInfo = localStorage.getItem("user_info"); + if (storedUserInfo) { + try { + const parsedUserInfo: UserInfo = JSON.parse(storedUserInfo); + if (parsedUserInfo?.email) { + try { + const hasAccess = await verifyAdminEmail(parsedUserInfo.email); + if (hasAccess) { + setUserInfo(parsedUserInfo); + setIsAuthenticated(true); + setError(null); + } else { + handleLogout(); + setError( + "You do not have admin access. Please contact the Codex team if you believe this is a mistake.", + ); } - } catch (err) { - setError('Failed to load OAuth configuration'); - } finally { - setLoading(false); - } - }; - - initializeApp(); - }, [handleLogout, verifyAdminEmail]); - - const refetchAllData = useCallback(async () => { - setIsLoadingEntries(true); - - try { - const [entries, fetchedTags, pendingSubmissions] = await Promise.all([ - fetchAllEntries(), - fetchTagsFromDatabase(), - fetchPendingSubmissionsFromDatabase(), - ]); - const sortedEntries = entries.sort((a, b) => a.name.localeCompare(b.name)); - setLexiconEntriesForDisplay(sortedEntries); - setTags(fetchedTags.sort((a, b) => a.name.localeCompare(b.name))); - setUserSubmissions(pendingSubmissions); - } catch (error) { - - toast({ - title: "Error Fetching Data", - description: "Could not load necessary data from the database.", - variant: "destructive", - }); - } finally { - setIsLoadingEntries(false); - setCurrentPage(1); - } - }, [toast]); - - useEffect(() => { - if (isAuthenticated) { - refetchAllData(); - } - }, [isAuthenticated, refetchAllData]); - - useEffect(() => { - const filtered = lexiconEntriesForDisplay.filter(entry => { - const matchesSearch = entry.name.toLowerCase().includes(searchTerm.toLowerCase()); - const matchesLetter = filterLetter === 'All' || entry.name.toLowerCase().startsWith(filterLetter.toLowerCase()); - return matchesSearch && matchesLetter; - }); - setFilteredEntries(filtered); - }, [lexiconEntriesForDisplay, searchTerm, filterLetter]); - - const handleAddNewEntry = () => { - setEditingEntry({ - type: 'lexicon', - id: '', - name: '', - description: '', - aliases: [], - }); - setIsEntryFormOpen(true); - }; - - const handleEditEntry = (entry: AnyEntry): void => { - setEditingEntry(entry); - setIsEntryFormOpen(true); - }; - - const handleDeleteEntry = async (entry: AnyEntry) => { - if (confirm(`Are you sure you want to delete "${entry.name}"? This action cannot be undone.`)) { - try { - await deleteEntryFromDatabase(entry.id); - toast({ title: "Entry Deleted", description: `"${entry.name}" has been deleted.` }); - await refetchAllData(); - } catch (error) { - - toast({ title: "Delete Failed", description: `Could not delete entry "${entry.name}".`, variant: "destructive" }); - } - } - }; - - const handleEntryFormSubmit = async (data: AnyEntry): Promise => { - setIsSubmitting(true); - try { - if (editingEntry?.id && editingEntry.id !== '') { - const dataToUpdate = { ...data, id: editingEntry.id }; - await updateEntryInDatabase(dataToUpdate); - toast({ title: "Entry Updated", description: `${data.name} has been updated successfully.` }); + } catch (verificationError) { + console.error( + "Failed to verify admin access:", + verificationError, + ); + setIsAuthenticated(false); + setError( + "Failed to verify admin access. Please try again later.", + ); + } } else { - const dataToCreate = data as Omit & { id?: string }; - if (dataToCreate.id && (dataToCreate.id.startsWith(`${dataToCreate.type}-`) || dataToCreate.id === '')) { - delete dataToCreate.id; - } - await createEntryInDatabase(dataToCreate); - toast({ title: "Entry Created", description: `${data.name} has been created successfully.` }); + handleLogout(); + setError( + "Unable to determine the authenticated email address. Please log in again.", + ); } - - await refetchAllData(); - - setIsEntryFormOpen(false); - setEditingEntry(undefined); - - } catch (error) { - const action = (editingEntry?.id && editingEntry.id !== '') ? "Updating" : "Creating"; - - - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - toast({ - title: `${action} Failed`, - description: `Could not ${action.toLowerCase()} entry "${data.name}". ${errorMessage}`, - variant: "destructive" - }); - throw error; - } finally { - setIsSubmitting(false); - } - }; - - const handleAddNewTag = () => { - setEditingTag(undefined); - setNewTagName(''); - setIsTagFormOpen(true); - }; - - const handleEditTag = (tag: Tag) => { - setEditingTag(tag); - setNewTagName(tag.name); - setIsTagFormOpen(true); - }; - - const handleTagFormSubmit = async () => { - if (!newTagName.trim()) { - toast({ title: "Tag name cannot be empty.", variant: "destructive" }); - return; - } - - try { - if (editingTag?.id) { - await updateTagInDatabase(editingTag.id, newTagName); - toast({ title: "Tag Updated", description: `Tag "${newTagName}" has been updated.` }); - } else { - await createTagInDatabase(newTagName); - toast({ title: "Tag Added", description: `Tag "${newTagName}" has been added.` }); - } - await refetchAllData(); - setNewTagName(''); - setEditingTag(undefined); - setIsTagFormOpen(false); - } catch (error) { - const action = editingTag ? "Update" : "Add"; - - toast({ title: `${action} Failed`, description: `Could not ${action.toLowerCase()} tag "${newTagName}". Name might be in use or another error occurred.`, variant: "destructive" }); + } catch (parseError) { + console.error("Failed to parse stored user info:", parseError); + localStorage.removeItem("user_info"); + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + } } + } catch (err) { + setError("Failed to load OAuth configuration"); + } finally { + setLoading(false); + } }; - const handleDeleteTag = async (tagId: string) => { - if (confirm(`Are you sure you want to delete this tag? This action cannot be undone and will remove the tag from all entries.`)) { - try { - await deleteTagFromDatabase(tagId); - await refetchAllData(); - toast({ title: "Tag Deleted" }); - } catch (error) { - - toast({ title: "Delete Failed", description: "Could not delete tag.", variant: "destructive" }); - } - } - }; - - const formatAliases = (aliases: string[] | Alias[] | undefined): string => { - if (!aliases || !Array.isArray(aliases) || aliases.length === 0) return 'None'; - - return aliases - .map(alias => { - if (typeof alias === 'string') return alias; - if (alias && typeof alias === 'object' && 'name' in alias && typeof alias.name === 'string') return alias.name; - return '[Invalid Alias]'; - }) - .join(', '); - }; - - const handleViewSubmission = async (submission: UserSubmissionBase) => { - setViewingSubmission(submission); - setEditedSubmissionData(JSON.parse(JSON.stringify(submission.data))); // Deep clone - setIsEditingSubmission(false); - setOriginalEntryForEditView(null); - if (submission.submissionType === 'edit') { - setIsLoadingOriginalEntry(true); - try { - const originalEntry = await fetchEntryById((submission.data as EditEntrySuggestionData).entryId); - setOriginalEntryForEditView(originalEntry); - } catch (error) { - - toast({ title: "Error", description: "Could not load original entry details.", variant: "destructive" }); - } finally { - setIsLoadingOriginalEntry(false); - } - } - setIsSubmissionDetailOpen(true); - }; - - const handleStartEditingSubmission = () => { - setIsEditingSubmission(true); - }; - - const handleCancelEditingSubmission = () => { - if (viewingSubmission) { - setEditedSubmissionData(JSON.parse(JSON.stringify(viewingSubmission.data))); - } - setIsEditingSubmission(false); - }; - - const handleSaveSubmissionEdits = () => { - if (viewingSubmission && editedSubmissionData) { - setViewingSubmission({ ...viewingSubmission, data: editedSubmissionData }); - } - setIsEditingSubmission(false); - toast({ title: "Changes Saved", description: "Your edits have been saved. You can now approve the submission." }); - }; - - const handleApproveSubmission = async (submissionId: number) => { - const submission = userSubmissions.find(s => s.id === submissionId); - if (submission) { - try { - await applyApprovedSubmissionToDatabase(submission); - await updateSubmissionStatusInDatabase(submissionId, 'approved', submission); - toast({ title: "Submission Approved", description: `Submission ID "${submissionId}" has been approved.` }); - await refetchAllData(); - if (viewingSubmission?.id === submissionId) setIsSubmissionDetailOpen(false); - } catch (error) { - - toast({ title: "Approval Failed", description: `Could not approve submission ID "${submissionId}". Details: ${(error as Error).message}`, variant: "destructive" }); - } - } - }; - - const handleRejectSubmission = async (submissionId: number) => { - const submission = userSubmissions.find(s => s.id === submissionId); - if (submission) { - try { - await updateSubmissionStatusInDatabase(submissionId, 'rejected', submission); - toast({ title: "Submission Rejected", description: `Submission ID "${submissionId}" has been rejected.` }); - await refetchAllData(); - if (viewingSubmission?.id === submissionId) setIsSubmissionDetailOpen(false); - } catch (error) { + initializeApp(); + }, [handleLogout]); + + const refetchAllData = useCallback(async () => { + setIsLoadingEntries(true); + + try { + const [entries, fetchedTags, pendingSubmissions] = await Promise.all([ + fetchAllEntries(), + fetchTagsFromDatabase(), + fetchPendingSubmissionsFromDatabase(), + ]); + const sortedEntries = entries.sort((a, b) => + a.name.localeCompare(b.name), + ); + setLexiconEntriesForDisplay(sortedEntries); + setTags(fetchedTags.sort((a, b) => a.name.localeCompare(b.name))); + setUserSubmissions(pendingSubmissions); + } catch (error) { + toast({ + title: "Error Fetching Data", + description: "Could not load necessary data from the database.", + variant: "destructive", + }); + } finally { + setIsLoadingEntries(false); + setCurrentPage(1); + } + }, [toast]); - toast({ title: "Reject Failed", description: `Could not reject submission ID "${submissionId}".`, variant: "destructive" }); - } + useEffect(() => { + if (isAuthenticated) { + refetchAllData(); + } + }, [isAuthenticated, refetchAllData]); + + useEffect(() => { + const filtered = lexiconEntriesForDisplay.filter((entry) => { + const matchesSearch = entry.name + .toLowerCase() + .includes(searchTerm.toLowerCase()); + const matchesLetter = + filterLetter === "All" || + entry.name.toLowerCase().startsWith(filterLetter.toLowerCase()); + return matchesSearch && matchesLetter; + }); + setFilteredEntries(filtered); + }, [lexiconEntriesForDisplay, searchTerm, filterLetter]); + + const handleAddNewEntry = () => { + setEditingEntry({ + type: "lexicon", + id: "", + name: "", + description: "", + aliases: [], + }); + setIsEntryFormOpen(true); + }; + + const handleEditEntry = (entry: AnyEntry): void => { + setEditingEntry(entry); + setIsEntryFormOpen(true); + }; + + const handleDeleteEntry = async (entry: AnyEntry) => { + if ( + confirm( + `Are you sure you want to delete "${entry.name}"? This action cannot be undone.`, + ) + ) { + try { + await deleteEntryFromDatabase(entry.id); + toast({ + title: "Entry Deleted", + description: `"${entry.name}" has been deleted.`, + }); + await refetchAllData(); + } catch (error) { + toast({ + title: "Delete Failed", + description: `Could not delete entry "${entry.name}".`, + variant: "destructive", + }); + } + } + }; + + const handleEntryFormSubmit = async (data: AnyEntry): Promise => { + setIsSubmitting(true); + try { + if (editingEntry?.id && editingEntry.id !== "") { + const dataToUpdate = { ...data, id: editingEntry.id }; + await updateEntryInDatabase(dataToUpdate); + toast({ + title: "Entry Updated", + description: `${data.name} has been updated successfully.`, + }); + } else { + const dataToCreate = data as Omit< + AnyEntry, + "id" | "linkedDescriptionHtml" + > & { id?: string }; + if ( + dataToCreate.id && + (dataToCreate.id.startsWith(`${dataToCreate.type}-`) || + dataToCreate.id === "") + ) { + delete dataToCreate.id; } - }; + await createEntryInDatabase(dataToCreate); + toast({ + title: "Entry Created", + description: `${data.name} has been created successfully.`, + }); + } + + await refetchAllData(); + + setIsEntryFormOpen(false); + setEditingEntry(undefined); + } catch (error) { + const action = + editingEntry?.id && editingEntry.id !== "" ? "Updating" : "Creating"; + + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred"; + toast({ + title: `${action} Failed`, + description: `Could not ${action.toLowerCase()} entry "${data.name}". ${errorMessage}`, + variant: "destructive", + }); + throw error; + } finally { + setIsSubmitting(false); + } + }; + + const handleAddNewTag = () => { + setEditingTag(undefined); + setNewTagName(""); + setIsTagFormOpen(true); + }; + + const handleEditTag = (tag: Tag) => { + setEditingTag(tag); + setNewTagName(tag.name); + setIsTagFormOpen(true); + }; + + const handleTagFormSubmit = async () => { + if (!newTagName.trim()) { + toast({ title: "Tag name cannot be empty.", variant: "destructive" }); + return; + } - if (loading) { - return ( -
-
-
-

Logging you in...

-
-
+ try { + if (editingTag?.id) { + await updateTagInDatabase(editingTag.id, newTagName); + toast({ + title: "Tag Updated", + description: `Tag "${newTagName}" has been updated.`, + }); + } else { + await createTagInDatabase(newTagName); + toast({ + title: "Tag Added", + description: `Tag "${newTagName}" has been added.`, + }); + } + await refetchAllData(); + setNewTagName(""); + setEditingTag(undefined); + setIsTagFormOpen(false); + } catch (error) { + const action = editingTag ? "Update" : "Add"; + + toast({ + title: `${action} Failed`, + description: `Could not ${action.toLowerCase()} tag "${newTagName}". Name might be in use or another error occurred.`, + variant: "destructive", + }); + } + }; + + const handleDeleteTag = async (tagId: string) => { + if ( + confirm( + `Are you sure you want to delete this tag? This action cannot be undone and will remove the tag from all entries.`, + ) + ) { + try { + await deleteTagFromDatabase(tagId); + await refetchAllData(); + toast({ title: "Tag Deleted" }); + } catch (error) { + toast({ + title: "Delete Failed", + description: "Could not delete tag.", + variant: "destructive", + }); + } + } + }; + + const formatAliases = (aliases: string[] | Alias[] | undefined): string => { + if (!aliases || !Array.isArray(aliases) || aliases.length === 0) + return "None"; + + return aliases + .map((alias) => { + if (typeof alias === "string") return alias; + if ( + alias && + typeof alias === "object" && + "name" in alias && + typeof alias.name === "string" + ) + return alias.name; + return "[Invalid Alias]"; + }) + .join(", "); + }; + + const handleViewSubmission = async (submission: UserSubmissionBase) => { + setViewingSubmission(submission); + setEditedSubmissionData(JSON.parse(JSON.stringify(submission.data))); // Deep clone + setIsEditingSubmission(false); + setOriginalEntryForEditView(null); + if (submission.submissionType === "edit") { + setIsLoadingOriginalEntry(true); + try { + const originalEntry = await fetchEntryById( + (submission.data as EditEntrySuggestionData).entryId, ); + setOriginalEntryForEditView(originalEntry); + } catch (error) { + toast({ + title: "Error", + description: "Could not load original entry details.", + variant: "destructive", + }); + } finally { + setIsLoadingOriginalEntry(false); + } } - - if (!isAuthenticated) { - return ( -
- - -
-
- - -
-
-
- Secure Admin Access - - Please log in with your F3 credentials to access admin tools. - -
-
- - - {error && ( -
-

{error}

-
- )} - -
- -
-
-
-
+ setIsSubmissionDetailOpen(true); + }; + + const handleStartEditingSubmission = () => { + setIsEditingSubmission(true); + }; + + const handleCancelEditingSubmission = () => { + if (viewingSubmission) { + setEditedSubmissionData( + JSON.parse(JSON.stringify(viewingSubmission.data)), + ); + } + setIsEditingSubmission(false); + }; + + const handleSaveSubmissionEdits = () => { + if (viewingSubmission && editedSubmissionData) { + setViewingSubmission({ + ...viewingSubmission, + data: editedSubmissionData, + }); + } + setIsEditingSubmission(false); + toast({ + title: "Changes Saved", + description: + "Your edits have been saved. You can now approve the submission.", + }); + }; + + const handleApproveSubmission = async (submissionId: number) => { + const submission = userSubmissions.find((s) => s.id === submissionId); + if (submission) { + try { + await applyApprovedSubmissionToDatabase(submission); + await updateSubmissionStatusInDatabase( + submissionId, + "approved", + submission, ); + toast({ + title: "Submission Approved", + description: `Submission ID "${submissionId}" has been approved.`, + }); + await refetchAllData(); + if (viewingSubmission?.id === submissionId) + setIsSubmissionDetailOpen(false); + } catch (error) { + toast({ + title: "Approval Failed", + description: `Could not approve submission ID "${submissionId}". Details: ${(error as Error).message}`, + variant: "destructive", + }); + } } - - if (error) { - return ( -
- - - Error - {error} - - - - - -
+ }; + + const handleRejectSubmission = async (submissionId: number) => { + const submission = userSubmissions.find((s) => s.id === submissionId); + if (submission) { + try { + await updateSubmissionStatusInDatabase( + submissionId, + "rejected", + submission, ); + toast({ + title: "Submission Rejected", + description: `Submission ID "${submissionId}" has been rejected.`, + }); + await refetchAllData(); + if (viewingSubmission?.id === submissionId) + setIsSubmissionDetailOpen(false); + } catch (error) { + toast({ + title: "Reject Failed", + description: `Could not reject submission ID "${submissionId}".`, + variant: "destructive", + }); + } } + }; + if (loading) { return ( - -
-
- -

Admin Panel

-

Manage Exicon, Lexicon, Tags, and User Submissions.

- {userInfo && ( -
- Welcome, {userInfo.name || userInfo.email || 'User'} - -
- )} -
- { - setIsEntryFormOpen(isOpen); - if (!isOpen) { - setEditingEntry(undefined); - } - }}> - - - - - - {(editingEntry?.id && editingEntry.id !== '') ? 'Edit Entry' : 'Create New Entry'} - - - - +
+
+
+

Logging you in...

+
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+ + +
+
+ + +
+
+
+ + Secure Admin Access + + + Please log in with your F3 credentials to access admin tools. +
+
+ + + {error && ( +
+

{error}

+
+ )} + +
+ +
+
+
+
+ ); + } - - - Manage Entries - View, edit, or delete existing Lexicon entries. - - + if (error) { + return ( +
+ + + Error + {error} + + + + + +
+ ); + } + + return ( + +
+
+ +

Admin Panel

+

+ Manage Exicon, Lexicon, Tags, and User Submissions. +

+ {userInfo && ( +
+ Welcome, {userInfo.name || userInfo.email || "User"} + +
+ )} +
+ { + setIsEntryFormOpen(isOpen); + if (!isOpen) { + setEditingEntry(undefined); + } + }} + > + + + + + + + {editingEntry?.id && editingEntry.id !== "" + ? "Edit Entry" + : "Create New Entry"} + + + + + +
+ + + + Manage Entries + + View, edit, or delete existing Lexicon entries. + + + + { + setSearchTerm(e.target.value); + setCurrentPage(1); + }} + className="w-full md:max-w-sm" + /> +
+ {["All", ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("")].map( + (letter) => ( + + ), + )} +
+
+ +
+ + + + Name + Type + Description (Snippet) + Actions + + + + {isLoadingEntries ? ( + + + Loading entries... + + + ) : filteredEntries.length > 0 ? ( + filteredEntries + .slice( + (currentPage - 1) * entriesPerPage, + currentPage * entriesPerPage, + ) + .map((entry) => ( + + + {entry.name} + + + {entry.type} + + + {entry.description} + + + + + + + )) + ) : ( + + + No entries found. + + + )} + +
+
+
+ + + + Page {filteredEntries.length > 0 ? currentPage : 0} of{" "} + {Math.ceil(filteredEntries.length / entriesPerPage)} + + + +
+ + + +
+ Manage Tags + + Add, edit, or delete tags used for Exicon entries. + +
+ { + setIsTagFormOpen(isOpen); + if (!isOpen) { + setEditingTag(undefined); + setNewTagName(""); + } + }} + > + + + + + + + {editingTag ? "Edit Tag" : "Add New Tag"} + + +
{ + e.preventDefault(); + handleTagFormSubmit(); + }} + > +
+
+ { - setSearchTerm(e.target.value); - setCurrentPage(1); - }} - className="w-full md:max-w-sm" + id="tagName" + value={newTagName} + onChange={(e) => setNewTagName(e.target.value)} + className="col-span-3" + required /> -
- {['All', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')].map((letter) => ( - - ))} -
- - -
- - - - Name - Type - Description (Snippet) - Actions - - - - {isLoadingEntries ? ( - - - Loading entries... - - - ) : filteredEntries.length > 0 ? ( - filteredEntries - .slice((currentPage - 1) * entriesPerPage, currentPage * entriesPerPage) - .map((entry) => ( - - {entry.name} - {entry.type} - {entry.description} - - - - - - )) - ) : ( - - - No entries found. - - - )} - -
-
-
- - - - Page {filteredEntries.length > 0 ? currentPage : 0} of {Math.ceil(filteredEntries.length / entriesPerPage)} - -
+
+ + + + +
+
+
+
+ + {tags.length > 0 ? ( +
+ + + + Tag Name + Actions + + + + {tags.map((tag) => ( + + {tag.name} + + + + + + ))} + +
+
+ ) : ( +

+ No tags found. Add some tags to get started. +

+ )} +
+
+ + + + + + User Submissions + + + Review and approve/reject new entries or edits submitted by users. + + + + {userSubmissions.length > 0 ? ( +
+ + + + Type + Entry Name / Suggested + Submitter + Date + Actions + + + + {userSubmissions.map((submission) => ( + + + + {submission.submissionType} + + + + {submission.submissionType === "new" + ? (submission.data as NewEntrySuggestionData).name + : (submission.data as EditEntrySuggestionData) + .entryName} + + + {submission.submitterName || "Anonymous"} + + + {new Date(submission.timestamp).toLocaleDateString()} + + + + + + + + ))} + +
+
+ ) : ( +

No pending submissions.

+ )} +
+
+ + + + +
+
+ + {isEditingSubmission + ? "Edit Submission" + : "Submission Details"} + + + {isEditingSubmission + ? "Make any necessary changes before approving" + : "Reviewing"}{" "} + {viewingSubmission?.submissionType === "new" + ? "new entry suggestion" + : `edit suggestion for `} + + {viewingSubmission?.submissionType === "new" + ? (viewingSubmission.data as NewEntrySuggestionData).name + : (viewingSubmission?.data as EditEntrySuggestionData) + ?.entryName} + + +
+ {!isEditingSubmission && ( + + )} +
+
+ {viewingSubmission && ( +
+
+
+

Submission Info

+

+ Submitted by:{" "} + {viewingSubmission.submitterName || "Anonymous"} +

+

+ Date:{" "} + {new Date(viewingSubmission.timestamp).toLocaleDateString()} +

+
+
+

Change Type

+

+ - Next - - - - - - -

- Manage Tags - Add, edit, or delete tags used for Exicon entries. -
- { - setIsTagFormOpen(isOpen); - if (!isOpen) { - setEditingTag(undefined); - setNewTagName(''); + {viewingSubmission.submissionType} + +

+ {viewingSubmission.submissionType === "edit" && ( +

+ Original Entry ID:{" "} + + { + (viewingSubmission.data as EditEntrySuggestionData) + .entryId } - }}> - - - - - - {editingTag ? 'Edit Tag' : 'Add New Tag'} - -

{ e.preventDefault(); handleTagFormSubmit(); }}> -
-
- - setNewTagName(e.target.value)} - className="col-span-3" - required - /> -
-
- - - - -
- -
- - - {tags.length > 0 ? ( -
- - - - Tag Name - Actions - - - - {tags.map((tag) => ( - - {tag.name} - - - - - - ))} - -
-
- ) : ( -

No tags found. Add some tags to get started.

- )} -
- - - - - User Submissions - Review and approve/reject new entries or edits submitted by users. - - - {userSubmissions.length > 0 ? ( -
- - - - Type - Entry Name / Suggested - Submitter - Date - Actions - - - - {userSubmissions.map((submission) => ( - - - - {submission.submissionType} - - - - {submission.submissionType === 'new' ? (submission.data as NewEntrySuggestionData).name : (submission.data as EditEntrySuggestionData).entryName} - - {submission.submitterName || 'Anonymous'} - {new Date(submission.timestamp).toLocaleDateString()} - - - - - - - ))} - -
-
- ) : ( -

No pending submissions.

- )} -
-
- - - - -
-
- {isEditingSubmission ? 'Edit Submission' : 'Submission Details'} - - {isEditingSubmission ? 'Make any necessary changes before approving' : 'Reviewing'} {viewingSubmission?.submissionType === 'new' ? 'new entry suggestion' : `edit suggestion for `} - - {viewingSubmission?.submissionType === 'new' - ? (viewingSubmission.data as NewEntrySuggestionData).name - : (viewingSubmission?.data as EditEntrySuggestionData)?.entryName} - - -
- {!isEditingSubmission && ( - - )} -
-
- {viewingSubmission && ( -
-
-
-

Submission Info

-

Submitted by: {viewingSubmission.submitterName || 'Anonymous'}

-

Date: {new Date(viewingSubmission.timestamp).toLocaleDateString()}

-
-
-

Change Type

-

- - {viewingSubmission.submissionType} - -

- {viewingSubmission.submissionType === 'edit' && ( -

- Original Entry ID: {(viewingSubmission.data as EditEntrySuggestionData).entryId} -

- )} + +

+ )} +
+
+ + + + {viewingSubmission.submissionType === "new" ? ( +
+

New Entry Details

+ {isEditingSubmission && editedSubmissionData ? ( +
+
+ + + setEditedSubmissionData({ + ...editedSubmissionData, + name: e.target.value, + } as NewEntrySuggestionData) + } + /> +
+
+ +