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/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/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", + }, + }); +};