diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a8fe0c..8aa993b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,15 +33,3 @@ jobs: - run: bun install --frozen-lockfile - run: bun run build - e2e: - name: E2E Tests - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - run: bun install --frozen-lockfile - - run: bunx playwright install chromium --with-deps - - run: bun run test:e2e diff --git a/bun.lock b/bun.lock index f0c2282..1d95da8 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,6 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.10", - "@playwright/test": "^1.57.0", "@sveltejs/adapter-node": "^5.4.0", "@sveltejs/kit": "^2.49.2", "@sveltejs/vite-plugin-svelte": "^6.2.1", @@ -255,8 +254,6 @@ "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], - "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], - "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.9", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA=="], @@ -633,10 +630,6 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], - - "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], - "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -813,8 +806,6 @@ "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], - "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], diff --git a/docs/knowledges/data-models.md b/docs/knowledges/data-models.md index a05b1ad..41b41da 100644 --- a/docs/knowledges/data-models.md +++ b/docs/knowledges/data-models.md @@ -59,6 +59,9 @@ members ├── name TEXT NOT NULL ├── bio TEXT ├── imageUrl TEXT +├── githubUrl TEXT +├── twitterUrl TEXT +├── websiteUrl TEXT ├── pageContent TEXT ├── viewCount INTEGER NOT NULL DEFAULT 0 ├── createdAt INTEGER NOT NULL diff --git a/docs/knowledges/image-upload.md b/docs/knowledges/image-upload.md index 2ff48b5..9a71d5b 100644 --- a/docs/knowledges/image-upload.md +++ b/docs/knowledges/image-upload.md @@ -42,8 +42,38 @@ const MAX_BASE64_SIZE = Math.ceil(10 * 1024 * 1024 * 1.37); - Base64 adds ~37% overhead - 10MB file → ~13.7MB base64 +## S3 Key Format + +Keys follow the format: `{folder}/{uuid}-{filename}.{ext}` + +Example: `articles/a1b2c3d4-e5f6-7890-abcd-ef1234567890-cover.webp` + +Allowed folders: `images`, `uploads`, `covers`, `avatars`, `articles`, `members`, `projects` + +## S3 Cleanup + +When images are changed or removed, the old S3 file is automatically deleted: + +- **Change**: Old image deleted after new upload succeeds +- **Remove**: Image deleted immediately when "Remove" button clicked +- **External URLs**: Non-S3 URLs (different host) are ignored safely + +The `removeByUrl` command in `storage.remote.ts` handles URL-to-key conversion server-side. + +## User Input Methods + +The `ImageUpload` component supports: + +1. **Click**: Click to open file picker +2. **Drag & Drop**: Drag image files onto the component +3. **Paste (Ctrl+V)**: Paste from clipboard anywhere on the page + +Paste is handled globally via `svelte:window onpaste` and skips INPUT/TEXTAREA elements to avoid conflicts. + ## Design Decisions - **Never reject user uploads**: Compress instead of refusing - **Quality over size**: Try high quality first, only reduce if needed - **GIF exception**: GIFs skip compression (animation would be lost) +- **Clean up S3**: Delete old files on change/remove to avoid orphans +- **Fire-and-forget cleanup**: S3 deletion errors don't block the UI diff --git a/drizzle/0006_add-member-social-links.sql b/drizzle/0006_add-member-social-links.sql new file mode 100644 index 0000000..e831e68 --- /dev/null +++ b/drizzle/0006_add-member-social-links.sql @@ -0,0 +1,3 @@ +ALTER TABLE "member" ADD COLUMN "github_url" text;--> statement-breakpoint +ALTER TABLE "member" ADD COLUMN "twitter_url" text;--> statement-breakpoint +ALTER TABLE "member" ADD COLUMN "website_url" text; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..782062f --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1023 @@ +{ + "id": "5fc2f5a1-ecc1-49a8-8301-26e5d0660a20", + "prevId": "5654cbb5-d96a-4775-b4fb-21fa64d1bd43", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.article": { + "name": "article", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "view_count": { + "name": "view_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "article_authorId_idx": { + "name": "article_authorId_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "article_published_publishedAt_idx": { + "name": "article_published_publishedAt_idx", + "columns": [ + { + "expression": "published", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "article_author_id_member_id_fk": { + "name": "article_author_id_member_id_fk", + "tableFrom": "article", + "tableTo": "member", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "article_slug_unique": { + "name": "article_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.article_slug_redirect": { + "name": "article_slug_redirect", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "old_slug": { + "name": "old_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_slug": { + "name": "new_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "article_slug_redirect_oldSlug_idx": { + "name": "article_slug_redirect_oldSlug_idx", + "columns": [ + { + "expression": "old_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "article_slug_redirect_articleId_idx": { + "name": "article_slug_redirect_articleId_idx", + "columns": [ + { + "expression": "article_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "article_slug_redirect_article_id_article_id_fk": { + "name": "article_slug_redirect_article_id_article_id_fk", + "tableFrom": "article_slug_redirect", + "tableTo": "article", + "columnsFrom": ["article_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "twitter_url": { + "name": "twitter_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_content": { + "name": "page_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "view_count": { + "name": "view_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + }, + "member_slug_unique": { + "name": "member_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_url": { + "name": "demo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "view_count": { + "name": "view_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_slug_unique": { + "name": "project_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_member": { + "name": "project_member", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "projectMember_pk": { + "name": "projectMember_pk", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_member_project_id_project_id_fk": { + "name": "project_member_project_id_project_id_fk", + "tableFrom": "project_member", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_member_member_id_member_id_fk": { + "name": "project_member_member_id_member_id_fk", + "tableFrom": "project_member", + "tableTo": "member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utcode_member_at": { + "name": "utcode_member_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preference": { + "name": "user_preference", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "default_author_id": { + "name": "default_author_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preference_user_id_user_id_fk": { + "name": "user_preference_user_id_user_id_fk", + "tableFrom": "user_preference", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_preference_default_author_id_member_id_fk": { + "name": "user_preference_default_author_id_member_id_fk", + "tableFrom": "user_preference", + "tableTo": "member", + "columnsFrom": ["default_author_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.view_log": { + "name": "view_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "viewed_at": { + "name": "viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "view_log_resourceType_resourceId_idx": { + "name": "view_log_resourceType_resourceId_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "view_log_viewedAt_idx": { + "name": "view_log_viewedAt_idx", + "columns": [ + { + "expression": "viewed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4d55efd..5f8f446 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1766647447093, "tag": "0005_boring_rockslide", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1767024203005, + "tag": "0006_add-member-social-links", + "breakpoints": true } ] } diff --git a/e2e/README.md b/e2e/README.md deleted file mode 100644 index 21e95bd..0000000 --- a/e2e/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# E2E Tests - -アドミンページの基本的な動作をテストするEnd-to-End (E2E) テストです。 - -## セットアップ - -Playwrightは既にインストール済みですが、初回実行時にブラウザをインストールする必要がある場合: - -```bash -bunx playwright install chromium -``` - -## テスト実行 - -### 全テストを実行 - -```bash -bun run test:e2e -``` - -### UIモードで実行(デバッグに便利) - -```bash -bun run test:e2e:ui -``` - -### ヘッドモードで実行(ブラウザを表示) - -```bash -bun run test:e2e:headed -``` - -## テスト環境 - -- テストは `UNSAFE_DISABLE_AUTH=true` 環境で実行されます -- playwright.config.ts で自動的にビルド & プレビューサーバーを起動します -- ベースURL: `http://localhost:3000` - -## カバーされているテスト - -1. **admin-auth.e2e.ts** - 認証バイパステスト - - UNSAFE_DISABLE_AUTH=true でアドミンページにアクセス可能か確認 - - 全アドミンセクションがログインページにリダイレクトされないか確認 - -2. **admin-dashboard.e2e.ts** - ダッシュボード - - ダッシュボードページの読み込み - - ナビゲーションリンクの存在確認 - -3. **admin-articles.e2e.ts** - 記事管理 - - 記事一覧ページの読み込み - - 新規記事作成ページへの遷移 - - 記事フォームの表示確認 - -4. **admin-projects.e2e.ts** - プロジェクト管理 - - プロジェクト一覧ページの読み込み - - 新規プロジェクト作成ページへの遷移 - - プロジェクトフォームの表示確認 - -5. **admin-members.e2e.ts** - メンバー管理 - - メンバー一覧ページの読み込み - - 新規メンバー作成ページへの遷移 - - メンバーフォームの表示確認 - -## CI環境 - -CI環境では、playwright.config.ts の設定により: -- リトライが2回自動実行されます -- ワーカー数が1に制限されます(安定性向上のため) -- 既存サーバーの再利用を無効化します - -## トラブルシューティング - -### テストが失敗する場合 - -1. ビルドが成功しているか確認: - ```bash - bun run build - ``` - -2. プレビューサーバーが起動するか確認: - ```bash - bun run preview - ``` - -3. データベースが正しくセットアップされているか確認 - -### スクリーンショット・トレース - -- 失敗時のスクリーンショット: `test-results/` ディレクトリ -- トレースファイル: 初回リトライ時に記録されます -- HTMLレポート: `playwright-report/` ディレクトリ - ```bash - bunx playwright show-report - ``` diff --git a/e2e/admin-articles.e2e.ts b/e2e/admin-articles.e2e.ts deleted file mode 100644 index cab1c4f..0000000 --- a/e2e/admin-articles.e2e.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("Admin Articles", () => { - test("should load articles list page", async ({ page }) => { - await page.goto("/admin/articles"); - - // Check page loaded successfully - await expect(page).toHaveURL(/\/admin\/articles/); - - // Page should have a heading or title - const heading = page.locator("h1, h2").first(); - await expect(heading).toBeVisible(); - }); - - test("should navigate to new article page", async ({ page }) => { - await page.goto("/admin/articles"); - - // Look for "New Article" or "Create" button/link - const newButton = page.getByRole("link", { name: /new|create/i }).first(); - - if (await newButton.isVisible()) { - await newButton.click(); - - // Should navigate to new article page - await expect(page).toHaveURL(/\/admin\/articles\/new/); - } - }); - - test("should display article form on new article page", async ({ page }) => { - await page.goto("/admin/articles/new"); - - // Check for form elements (title, content, etc.) - const form = page.locator("form").first(); - await expect(form).toBeVisible(); - - // Should have input fields - const inputs = page.locator("input, textarea"); - await expect(inputs.first()).toBeVisible(); - }); -}); diff --git a/e2e/admin-auth.e2e.ts b/e2e/admin-auth.e2e.ts deleted file mode 100644 index 0039469..0000000 --- a/e2e/admin-auth.e2e.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("Admin Authentication", () => { - test("should allow access to admin pages when UNSAFE_DISABLE_AUTH is true", async ({ page }) => { - // With UNSAFE_DISABLE_AUTH=true, admin pages should be accessible - await page.goto("/admin"); - - // Should not redirect to login - await expect(page).toHaveURL(/\/admin/); - - // Page should load successfully - const heading = page.locator("h1, h2").first(); - await expect(heading).toBeVisible(); - }); - - test("should be able to access all admin sections without authentication", async ({ page }) => { - const adminPaths = ["/admin", "/admin/articles", "/admin/projects", "/admin/members"]; - - for (const path of adminPaths) { - await page.goto(path); - - // Should not redirect to login page - await expect(page).not.toHaveURL(/\/login/); - - // Page should load with content - await expect(page.locator("body")).toBeVisible(); - } - }); -}); diff --git a/e2e/admin-dashboard.e2e.ts b/e2e/admin-dashboard.e2e.ts deleted file mode 100644 index 05f0b42..0000000 --- a/e2e/admin-dashboard.e2e.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("Admin Dashboard", () => { - test("should load admin dashboard page", async ({ page }) => { - await page.goto("/admin"); - - // Dashboard should be accessible - await expect(page).toHaveTitle(/Admin/i); - - // Check for common dashboard elements - await expect(page.locator("h1")).toBeVisible(); - }); - - test("should have navigation links to admin sections", async ({ page }) => { - await page.goto("/admin"); - - // Check for navigation to main admin sections - const articlesLink = page.getByRole("link", { name: /articles/i }); - const projectsLink = page.getByRole("link", { name: /projects/i }); - const membersLink = page.getByRole("link", { name: /members/i }); - - // At least one of these should exist in the navigation - const hasNavigation = - (await articlesLink.count()) > 0 || - (await projectsLink.count()) > 0 || - (await membersLink.count()) > 0; - - expect(hasNavigation).toBe(true); - }); -}); diff --git a/e2e/admin-members.e2e.ts b/e2e/admin-members.e2e.ts deleted file mode 100644 index c1f213e..0000000 --- a/e2e/admin-members.e2e.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("Admin Members", () => { - test("should load members list page", async ({ page }) => { - await page.goto("/admin/members"); - - // Check page loaded successfully - await expect(page).toHaveURL(/\/admin\/members/); - - // Page should have a heading or title - const heading = page.locator("h1, h2").first(); - await expect(heading).toBeVisible(); - }); - - test("should navigate to new member page", async ({ page }) => { - await page.goto("/admin/members"); - - // Look for "New Member" or "Create" button/link - const newButton = page.getByRole("link", { name: /new|create|add/i }).first(); - - if (await newButton.isVisible()) { - await newButton.click(); - - // Should navigate to new member page - await expect(page).toHaveURL(/\/admin\/members\/new/); - } - }); - - test("should display member form on new member page", async ({ page }) => { - await page.goto("/admin/members/new"); - - // Check for form elements - const form = page.locator("form").first(); - await expect(form).toBeVisible(); - - // Should have input fields - const inputs = page.locator("input, textarea, select"); - await expect(inputs.first()).toBeVisible(); - }); -}); diff --git a/e2e/admin-projects.e2e.ts b/e2e/admin-projects.e2e.ts deleted file mode 100644 index e70ceaf..0000000 --- a/e2e/admin-projects.e2e.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test.describe("Admin Projects", () => { - test("should load projects list page", async ({ page }) => { - await page.goto("/admin/projects"); - - // Check page loaded successfully - await expect(page).toHaveURL(/\/admin\/projects/); - - // Page should have a heading or title - const heading = page.locator("h1, h2").first(); - await expect(heading).toBeVisible(); - }); - - test("should navigate to new project page", async ({ page }) => { - await page.goto("/admin/projects"); - - // Look for "New Project" or "Create" button/link - const newButton = page.getByRole("link", { name: /new|create/i }).first(); - - if (await newButton.isVisible()) { - await newButton.click(); - - // Should navigate to new project page - await expect(page).toHaveURL(/\/admin\/projects\/new/); - } - }); - - test("should display project form on new project page", async ({ page }) => { - await page.goto("/admin/projects/new"); - - // Check for form elements - const form = page.locator("form").first(); - await expect(form).toBeVisible(); - - // Should have input fields - const inputs = page.locator("input, textarea"); - await expect(inputs.first()).toBeVisible(); - }); -}); diff --git a/package.json b/package.json index 787f8ec..b36c762 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "reload": "bun down && bun up", "attach": "devenv processes up", "logs": "tail -f ./.devenv/processes.log", - "test:e2e": "playwright test", "check": "bun type-check && bun test-check && bun lint-check && bun format-check", "fix": "biome check --fix && prettier --write .", "tidy": "bun type-check && bun test-check && biome check --fix && prettier --write .", @@ -28,7 +27,6 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.10", - "@playwright/test": "^1.57.0", "@sveltejs/adapter-node": "^5.4.0", "@sveltejs/kit": "^2.49.2", "@sveltejs/vite-plugin-svelte": "^6.2.1", diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index af5f076..0000000 --- a/playwright.config.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; - -/** - * Playwright configuration for e2e testing - * @see https://playwright.dev/docs/test-configuration - */ -export default defineConfig({ - testDir: "./e2e", - testMatch: "**/*.e2e.ts", - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: "html", - use: { - baseURL: "http://localhost:3000", - trace: "on-first-retry", - screenshot: "only-on-failure", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - webServer: { - command: "bun run build && bun run preview", - url: "http://localhost:3000", - reuseExistingServer: !process.env.CI, - env: { - UNSAFE_DISABLE_AUTH: "true", - }, - }, -}); diff --git a/src/lib/components/ArticleForm.svelte b/src/lib/components/ArticleForm.svelte index d1a9ccf..c3fb7cb 100644 --- a/src/lib/components/ArticleForm.svelte +++ b/src/lib/components/ArticleForm.svelte @@ -5,7 +5,7 @@ import { triggerSubmit } from "$lib/utils/form"; import { onSaveShortcut } from "$lib/utils/keyboard"; import { snapshot } from "$lib/utils/snapshot.svelte"; - import { ArticleEditor, ArticleFormHeader, ArticleSettings } from "./article-form"; + import { ArticleEditor, ArticleFormHeader } from "./article-form"; import { confirm } from "$lib/components/confirm-modal.svelte"; let { @@ -23,7 +23,6 @@ submitLabel = "Save", isSubmitting = $bindable(false), articleId = null, - viewCount = 0, }: { initialData?: ArticleData; authors?: Author[]; @@ -32,12 +31,10 @@ submitLabel?: string; isSubmitting?: boolean; articleId?: string | null; - viewCount?: number; } = $props(); let formData = $state(snapshot(() => initialData)); let errors = $state>({}); - let showSettings = $state(false); let saveSuccess = $state(false); let createRedirect = $state(false); @@ -64,8 +61,6 @@ errors = validator.getErrors(); if (validator.hasErrors()) { - // Show settings panel if there are errors in settings fields - if (errors.slug) showSettings = true; return; } @@ -95,46 +90,33 @@ window.open(`/admin/articles/${articleId}/preview`, "_blank"); } } - triggerSubmit(handleSubmit, isSubmitting))} /> -
+ -
- (showSettings = true)} - /> - - -
+ diff --git a/src/lib/components/MemberForm.svelte b/src/lib/components/MemberForm.svelte index 5c113b4..47cece5 100644 --- a/src/lib/components/MemberForm.svelte +++ b/src/lib/components/MemberForm.svelte @@ -5,27 +5,33 @@ import { triggerSubmit } from "$lib/utils/form"; import { onSaveShortcut } from "$lib/utils/keyboard"; import { snapshot } from "$lib/utils/snapshot.svelte"; - import { MemberEditor, MemberFormHeader, MemberSettings } from "./member-form"; + import { MemberEditor, MemberFormHeader } from "./member-form"; let { - initialData = { slug: "", name: "", bio: "", imageUrl: "", pageContent: "" }, + initialData = { + slug: "", + name: "", + bio: "", + imageUrl: "", + githubUrl: "", + twitterUrl: "", + websiteUrl: "", + pageContent: "", + }, onSubmit, onDelete = null, submitLabel = "Save", isSubmitting = $bindable(false), - viewCount = 0, }: { initialData?: MemberData; onSubmit: (data: MemberData) => Promise; onDelete?: (() => Promise) | null; submitLabel?: string; isSubmitting?: boolean; - viewCount?: number; } = $props(); let formData = $state(snapshot(() => initialData)); let errors = $state>({}); - let showSettings = $state(false); function handleNameChange() { if (!formData.slug || formData.slug === generateSlug(initialData.name)) { @@ -47,7 +53,6 @@ errors = validator.getErrors(); if (validator.hasErrors()) { - if (errors.slug) showSettings = true; return; } @@ -62,32 +67,20 @@ triggerSubmit(handleSubmit, isSubmitting))} /> -
- - -
- (showSettings = true)} - /> + + - -
+ diff --git a/src/lib/components/ProjectForm.svelte b/src/lib/components/ProjectForm.svelte index 0a53a3b..a9c95fd 100644 --- a/src/lib/components/ProjectForm.svelte +++ b/src/lib/components/ProjectForm.svelte @@ -7,7 +7,6 @@ import { snapshot } from "$lib/utils/snapshot.svelte"; import ProjectEditor from "./project-form/ProjectEditor.svelte"; import ProjectFormHeader from "./project-form/ProjectFormHeader.svelte"; - import ProjectSettings from "./project-form/ProjectSettings.svelte"; let { initialData = { @@ -27,7 +26,6 @@ submitLabel = "Save", isSubmitting = $bindable(false), isNew = false, - viewCount = 0, }: { initialData?: ProjectData; members?: Member[]; @@ -36,12 +34,11 @@ submitLabel?: string; isSubmitting?: boolean; isNew?: boolean; - viewCount?: number; } = $props(); let formData = $state(snapshot(() => initialData)); let errors = $state>({}); - let showSettings = $state(false); + let saveSuccess = $state(false); function handleNameChange() { if (!formData.slug || formData.slug === generateSlug(initialData.name)) { @@ -62,19 +59,19 @@ if (isNew && !formData.leadMemberId) { validator.validate("leadMemberId", "", () => "Lead member is required"); - showSettings = true; } errors = validator.getErrors(); if (validator.hasErrors()) { - if (errors.slug || errors.leadMemberId) showSettings = true; return; } isSubmitting = true; + saveSuccess = false; try { await onSubmit(formData); + saveSuccess = true; } finally { isSubmitting = false; } @@ -83,41 +80,26 @@ triggerSubmit(handleSubmit, isSubmitting))} /> -
+ (showSettings = !showSettings)} + {onDelete} /> -
- (showSettings = true)} - /> - - (showSettings = false)} - {onDelete} - /> -
+ diff --git a/src/lib/components/admin-dashboard/AnalyticsBrief.svelte b/src/lib/components/admin-dashboard/AnalyticsBrief.svelte index e193b6b..d167ad8 100644 --- a/src/lib/components/admin-dashboard/AnalyticsBrief.svelte +++ b/src/lib/components/admin-dashboard/AnalyticsBrief.svelte @@ -2,25 +2,6 @@ import { ArrowRight, BarChart3, Eye, FileText, FolderKanban, Users } from "lucide-svelte"; import ViewTrendChart from "$lib/components/analytics/ViewTrendChart.svelte"; - interface TopItem { - id: string; - slug: string; - viewCount: number; - } - - interface TopArticle extends TopItem { - title: string; - author?: { name: string } | null; - } - - interface TopProject extends TopItem { - name: string; - } - - interface TopMember extends TopItem { - name: string; - } - interface ViewTrendData { date: string; count: number; @@ -31,9 +12,6 @@ totalArticleViews: number; totalProjectViews: number; totalMemberViews: number; - topArticles: TopArticle[]; - topProjects: TopProject[]; - topMembers: TopMember[]; viewTrend?: ViewTrendData[]; } @@ -42,25 +20,8 @@ totalArticleViews, totalProjectViews, totalMemberViews, - topArticles, - topProjects, - topMembers, viewTrend = [], }: Props = $props(); - - const topThreeArticles = $derived(topArticles.slice(0, 3)); - const topThreeProjects = $derived(topProjects.slice(0, 3)); - const topThreeMembers = $derived(topMembers.slice(0, 3)); - - const recentTrend = $derived(() => { - if (viewTrend.length < 2) return { value: 0, isPositive: true }; - const recent = viewTrend.slice(-7); - const older = viewTrend.slice(-14, -7); - const recentSum = recent.reduce((sum, d) => sum + d.count, 0); - const olderSum = older.reduce((sum, d) => sum + d.count, 0); - const change = olderSum === 0 ? 0 : ((recentSum - olderSum) / olderSum) * 100; - return { value: Math.abs(change), isPositive: change >= 0 }; - });
@@ -119,96 +80,4 @@ {/if} - -
- - {#if topThreeArticles.length > 0} - - {/if} - - - {#if topThreeProjects.length > 0} -
-

Top Projects

-
- {#each topThreeProjects as project, index (project.id)} - - - {index + 1} - -
-

{project.name}

-
-
- - {project.viewCount.toLocaleString()} -
-
- {/each} -
-
- {/if} - - - {#if topThreeMembers.length > 0} -
-

Top Member Pages

-
- {#each topThreeMembers as member, index (member.id)} - - - {index + 1} - -
-

{member.name}

-
-
- - {member.viewCount.toLocaleString()} -
-
- {/each} -
-
- {/if} -
diff --git a/src/lib/components/article-form/ArticleEditor.svelte b/src/lib/components/article-form/ArticleEditor.svelte index 3b580b7..f7ebbf2 100644 --- a/src/lib/components/article-form/ArticleEditor.svelte +++ b/src/lib/components/article-form/ArticleEditor.svelte @@ -1,28 +1,86 @@ -
+
- - {#if coverUrl} - - {/if} + 0} + class:border-red-300={displayError} + placeholder="title-slug" + /> +
- - {#if slug} - - {/if} + {#if displayError} +

{displayError}

+ {/if} + + + {#if initialSlug && slug !== initialSlug} + + {/if} + (null), + authors = [], isSubmitting = false, saveSuccess = $bindable(false), submitLabel = "Save", articleId = null, onPreview = null, + onDelete = null, }: { published?: boolean; - showSettings?: boolean; + authorId?: string | null; + authors?: Author[]; isSubmitting?: boolean; saveSuccess?: boolean; submitLabel?: string; articleId?: string | null; onPreview?: (() => void) | null; + onDelete?: (() => Promise) | null; } = $props(); let successTimeout: ReturnType | null = null; + let authorMenuOpen = $state(false); + let moreMenuOpen = $state(false); $effect(() => { if (saveSuccess) { @@ -53,6 +69,8 @@ onPreview(); } } + + const selectedAuthor = $derived(authors.find((a) => a.id === authorId)); @@ -172,8 +192,12 @@ -
- - - {#if migrationState?.status === "completed" || migrationState?.status === "error"} - @@ -271,8 +306,8 @@ id="logs" bind:this={logsContainer} class="h-80 overflow-auto rounded-lg bg-base-300 p-4 font-mono text-xs whitespace-pre-wrap">{#if migrationState?.logs.length}{migrationState.logs.join( - "\n", - )}{:else}No logs yet. Click "Start Migration" to begin.{/if} + "\n", + )}{:else}No logs yet. Click "Start Migration" to begin.{/if}
diff --git a/src/routes/(admin)/admin/projects/edit/[id]/+page.svelte b/src/routes/(admin)/admin/projects/edit/[id]/+page.svelte index 974a0e4..197ae88 100644 --- a/src/routes/(admin)/admin/projects/edit/[id]/+page.svelte +++ b/src/routes/(admin)/admin/projects/edit/[id]/+page.svelte @@ -185,7 +185,7 @@ {:else} -
+
(showAddMember = true)} @@ -225,7 +225,6 @@ onDelete={handleDelete} submitLabel="Save" bind:isSubmitting - viewCount={project.viewCount} />
diff --git a/src/routes/(admin)/admin/projects/new/+page.svelte b/src/routes/(admin)/admin/projects/new/+page.svelte index 2eced35..2f79584 100644 --- a/src/routes/(admin)/admin/projects/new/+page.svelte +++ b/src/routes/(admin)/admin/projects/new/+page.svelte @@ -54,6 +54,6 @@ New Project - ut.code(); CMS -
+
diff --git a/src/routes/(site)/members/[slug]/+page.svelte b/src/routes/(site)/members/[slug]/+page.svelte index 9437fc3..9d8a3d7 100644 --- a/src/routes/(site)/members/[slug]/+page.svelte +++ b/src/routes/(site)/members/[slug]/+page.svelte @@ -1,9 +1,13 @@ @@ -52,6 +56,43 @@ {#if data.member.bio}

{data.member.bio}

{/if} + {#if hasSocialLinks} +
+ {#if data.member.githubUrl} + + + + {/if} + {#if data.member.twitterUrl} + + + + {/if} + {#if data.member.websiteUrl} + + + + {/if} +
+ {/if}
diff --git a/src/routes/api/migration/events/+server.ts b/src/routes/api/migration/events/+server.ts new file mode 100644 index 0000000..bcbc0ec --- /dev/null +++ b/src/routes/api/migration/events/+server.ts @@ -0,0 +1,79 @@ +/** + * SSE endpoint for real-time migration updates + * + * Clients connect via EventSource and receive state updates as they happen. + * Initial connection sends full state, subsequent updates send only new logs. + */ + +import { requireUtCodeMember } from "$lib/server/database/auth.server"; +import { migrationActor } from "$lib/server/services/migration/state.server"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ request }) => { + await requireUtCodeMember(); + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + let closed = false; + + function cleanup() { + if (closed) return; + closed = true; + clearInterval(heartbeat); + unsubscribe(); + try { + controller.close(); + } catch { + // Already closed + } + } + + // Send initial state + const initialState = migrationActor.getState(); + const initEvent = `event: init\ndata: ${JSON.stringify(initialState)}\n\n`; + controller.enqueue(encoder.encode(initEvent)); + + // Subscribe to updates + const unsubscribe = migrationActor.subscribe((state, newLogs) => { + if (closed) return; + try { + const update = { + status: state.status, + startedAt: state.startedAt, + completedAt: state.completedAt, + result: state.result, + error: state.error, + newLogs, + }; + const updateEvent = `event: update\ndata: ${JSON.stringify(update)}\n\n`; + controller.enqueue(encoder.encode(updateEvent)); + } catch { + cleanup(); + } + }); + + // Send heartbeat every 30s to keep connection alive + const heartbeat = setInterval(() => { + if (closed) return; + try { + controller.enqueue(encoder.encode(":heartbeat\n\n")); + } catch { + cleanup(); + } + }, 30000); + + // Cleanup on abort + request.signal.addEventListener("abort", cleanup); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-store", + Connection: "keep-alive", + }, + }); +};