From 4322383ed87f6b796b6990922ab1cd2a4169f6b1 Mon Sep 17 00:00:00 2001 From: grabowski Date: Mon, 13 Apr 2026 15:42:34 +0700 Subject: [PATCH] Add Wiki feature with Markdown, categories, tags, and search - wiki_categories, wiki_pages, wiki_tags, wiki_page_tags tables - /wiki overview with category-grouped pages, tag sidebar, search - /wiki/new create page with Markdown editor and live preview - /wiki/[slug] view page with rendered Markdown, tags, edit/delete - /wiki/[slug]/edit with pre-populated editor - Tag input with existing tag suggestions (click to add) - Category management (add/delete) in sidebar - Tailwind Typography plugin for prose styling - marked package for Markdown rendering - Sidebar nav item with book icon Run db:push on server to create wiki tables. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 58 ++++- package.json | 2 + src/app.css | 1 + src/lib/components/layout/Sidebar.svelte | 5 + src/lib/server/db/schema.ts | 51 +++++ src/lib/utils/slug.ts | 9 + src/routes/(app)/wiki/+page.server.ts | 132 +++++++++++ src/routes/(app)/wiki/+page.svelte | 213 ++++++++++++++++++ src/routes/(app)/wiki/[slug]/+page.server.ts | 47 ++++ src/routes/(app)/wiki/[slug]/+page.svelte | 82 +++++++ .../(app)/wiki/[slug]/edit/+page.server.ts | 80 +++++++ .../(app)/wiki/[slug]/edit/+page.svelte | 100 ++++++++ src/routes/(app)/wiki/new/+page.server.ts | 58 +++++ src/routes/(app)/wiki/new/+page.svelte | 101 +++++++++ 14 files changed, 938 insertions(+), 1 deletion(-) create mode 100644 src/lib/utils/slug.ts create mode 100644 src/routes/(app)/wiki/+page.server.ts create mode 100644 src/routes/(app)/wiki/+page.svelte create mode 100644 src/routes/(app)/wiki/[slug]/+page.server.ts create mode 100644 src/routes/(app)/wiki/[slug]/+page.svelte create mode 100644 src/routes/(app)/wiki/[slug]/edit/+page.server.ts create mode 100644 src/routes/(app)/wiki/[slug]/edit/+page.svelte create mode 100644 src/routes/(app)/wiki/new/+page.server.ts create mode 100644 src/routes/(app)/wiki/new/+page.svelte diff --git a/package-lock.json b/package-lock.json index 0696615..d2fa5f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,12 @@ "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", + "@tailwindcss/typography": "^0.5.19", "bwip-js": "^4.9.0", "date-fns": "^4.1.0", "dotenv": "^17.4.1", "drizzle-orm": "^0.38.4", + "marked": "^18.0.0", "pg": "^8.13.1", "qrcode": "^1.5.4", "sharp": "^0.33.5", @@ -2487,6 +2489,18 @@ "node": ">= 20" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@tailwindcss/vite": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", @@ -2763,6 +2777,18 @@ "node": ">= 0.6" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -3583,6 +3609,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz", + "integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -3828,6 +3866,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -4248,7 +4299,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "dev": true, "license": "MIT" }, "node_modules/tapable": { @@ -4824,6 +4874,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/package.json b/package.json index 04fea00..745239b 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,12 @@ "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", + "@tailwindcss/typography": "^0.5.19", "bwip-js": "^4.9.0", "date-fns": "^4.1.0", "dotenv": "^17.4.1", "drizzle-orm": "^0.38.4", + "marked": "^18.0.0", "pg": "^8.13.1", "qrcode": "^1.5.4", "sharp": "^0.33.5", diff --git a/src/app.css b/src/app.css index 5b73331..767ce49 100644 --- a/src/app.css +++ b/src/app.css @@ -1,3 +1,4 @@ @import 'tailwindcss'; +@plugin '@tailwindcss/typography'; @custom-variant dark (&:where(.dark, .dark *)); diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 5983750..18f845f 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -58,6 +58,11 @@ label: 'Gallery', icon: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z' }, + { + href: '/wiki', + label: 'Wiki', + icon: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253' + }, { href: '/feature-requests', label: 'Requests', diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 645eae1..ee7dff7 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -352,3 +352,54 @@ export const featureRequests = pgTable('feature_requests', { createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() }); + +// ─── Wiki ─────────────────────────────────────────────────────────── + +export const wikiCategories = pgTable('wiki_categories', { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').notNull(), + slug: text('slug').unique().notNull(), + description: text('description'), + sortOrder: integer('sort_order').default(0).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() +}); + +export const wikiPages = pgTable( + 'wiki_pages', + { + id: uuid('id').defaultRandom().primaryKey(), + title: text('title').notNull(), + slug: text('slug').unique().notNull(), + content: text('content').notNull(), + categoryId: uuid('category_id').references(() => wikiCategories.id, { onDelete: 'set null' }), + createdBy: text('created_by'), + updatedBy: text('updated_by'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull() + }, + (table) => [ + index('wiki_pages_category_idx').on(table.categoryId), + index('wiki_pages_slug_idx').on(table.slug) + ] +); + +export const wikiTags = pgTable('wiki_tags', { + id: uuid('id').defaultRandom().primaryKey(), + name: text('name').unique().notNull() +}); + +export const wikiPageTags = pgTable( + 'wiki_page_tags', + { + pageId: uuid('page_id') + .notNull() + .references(() => wikiPages.id, { onDelete: 'cascade' }), + tagId: uuid('tag_id') + .notNull() + .references(() => wikiTags.id, { onDelete: 'cascade' }) + }, + (table) => [ + index('wiki_page_tags_page_idx').on(table.pageId), + index('wiki_page_tags_tag_idx').on(table.tagId) + ] +); diff --git a/src/lib/utils/slug.ts b/src/lib/utils/slug.ts new file mode 100644 index 0000000..0adffa4 --- /dev/null +++ b/src/lib/utils/slug.ts @@ -0,0 +1,9 @@ +export function slugify(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_]+/g, '-') + .replace(/^-+|-+$/g, '') + .substring(0, 100); +} diff --git a/src/routes/(app)/wiki/+page.server.ts b/src/routes/(app)/wiki/+page.server.ts new file mode 100644 index 0000000..d4f637f --- /dev/null +++ b/src/routes/(app)/wiki/+page.server.ts @@ -0,0 +1,132 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { wikiPages, wikiCategories, wikiTags, wikiPageTags } from '$lib/server/db/schema.js'; +import { eq, ilike, or, desc, sql, count } from 'drizzle-orm'; +import { fail } from '@sveltejs/kit'; +import { slugify } from '$lib/utils/slug.js'; + +export const load: PageServerLoad = async ({ url }) => { + const search = url.searchParams.get('q'); + const tagFilter = url.searchParams.get('tag'); + + // Categories + const categories = await db + .select() + .from(wikiCategories) + .orderBy(wikiCategories.sortOrder, wikiCategories.name); + + // Pages query + let pagesQuery; + if (tagFilter) { + // Filter by tag + const [tag] = await db.select().from(wikiTags).where(eq(wikiTags.name, tagFilter)); + if (tag) { + const taggedPageIds = await db + .select({ pageId: wikiPageTags.pageId }) + .from(wikiPageTags) + .where(eq(wikiPageTags.tagId, tag.id)); + const ids = taggedPageIds.map((r) => r.pageId); + + if (ids.length > 0) { + pagesQuery = db + .select({ + id: wikiPages.id, + title: wikiPages.title, + slug: wikiPages.slug, + categoryId: wikiPages.categoryId, + categoryName: wikiCategories.name, + updatedAt: wikiPages.updatedAt, + updatedBy: wikiPages.updatedBy + }) + .from(wikiPages) + .leftJoin(wikiCategories, eq(wikiPages.categoryId, wikiCategories.id)) + .where(sql`${wikiPages.id} IN ${ids}`) + .orderBy(desc(wikiPages.updatedAt)); + } + } + } else if (search) { + pagesQuery = db + .select({ + id: wikiPages.id, + title: wikiPages.title, + slug: wikiPages.slug, + categoryId: wikiPages.categoryId, + categoryName: wikiCategories.name, + updatedAt: wikiPages.updatedAt, + updatedBy: wikiPages.updatedBy + }) + .from(wikiPages) + .leftJoin(wikiCategories, eq(wikiPages.categoryId, wikiCategories.id)) + .where(or(ilike(wikiPages.title, `%${search}%`), ilike(wikiPages.content, `%${search}%`))) + .orderBy(desc(wikiPages.updatedAt)); + } else { + pagesQuery = db + .select({ + id: wikiPages.id, + title: wikiPages.title, + slug: wikiPages.slug, + categoryId: wikiPages.categoryId, + categoryName: wikiCategories.name, + updatedAt: wikiPages.updatedAt, + updatedBy: wikiPages.updatedBy + }) + .from(wikiPages) + .leftJoin(wikiCategories, eq(wikiPages.categoryId, wikiCategories.id)) + .orderBy(desc(wikiPages.updatedAt)); + } + + const pages = pagesQuery ? await pagesQuery : []; + + // Get tags for each page + const pageIds = pages.map((p) => p.id); + let tagsByPage: Record = {}; + if (pageIds.length > 0) { + const pageTags = await db + .select({ + pageId: wikiPageTags.pageId, + tagName: wikiTags.name + }) + .from(wikiPageTags) + .innerJoin(wikiTags, eq(wikiPageTags.tagId, wikiTags.id)) + .where(sql`${wikiPageTags.pageId} IN ${pageIds}`); + + for (const pt of pageTags) { + if (!tagsByPage[pt.pageId]) tagsByPage[pt.pageId] = []; + tagsByPage[pt.pageId].push(pt.tagName); + } + } + + // All tags with counts + const allTags = await db + .select({ name: wikiTags.name, count: count() }) + .from(wikiTags) + .innerJoin(wikiPageTags, eq(wikiTags.id, wikiPageTags.tagId)) + .groupBy(wikiTags.name) + .orderBy(wikiTags.name); + + return { + categories, + pages: pages.map((p) => ({ ...p, tags: tagsByPage[p.id] ?? [] })), + allTags, + filters: { search, tag: tagFilter } + }; +}; + +export const actions: Actions = { + createCategory: async ({ request }) => { + const formData = await request.formData(); + const name = (formData.get('name') as string)?.trim(); + if (!name) return fail(400, { error: 'Category name is required' }); + + const slug = slugify(name); + await db.insert(wikiCategories).values({ name, slug }); + return { categoryCreated: true }; + }, + + deleteCategory: async ({ request }) => { + const formData = await request.formData(); + const id = formData.get('id') as string; + await db.delete(wikiCategories).where(eq(wikiCategories.id, id)); + return { categoryDeleted: true }; + } +}; diff --git a/src/routes/(app)/wiki/+page.svelte b/src/routes/(app)/wiki/+page.svelte new file mode 100644 index 0000000..1e3e2ab --- /dev/null +++ b/src/routes/(app)/wiki/+page.svelte @@ -0,0 +1,213 @@ + + + + Wiki - My Collection + + +
+
+

Wiki

+ + New Page + +
+ + +
+
+ +
+ {#if data.filters.tag} +
+ Tag: + {data.filters.tag} + Clear +
+ {/if} +
+ +
+ +
+ {#if data.pages.length === 0} +
+

+ {#if data.filters.search} + No pages found for "{data.filters.search}". + {:else if data.filters.tag} + No pages with tag "{data.filters.tag}". + {:else} + No wiki pages yet. Create your first page! + {/if} +

+
+ {:else if data.filters.search || data.filters.tag} + + + {:else} + + {#each data.categories as cat} + {@const catPages = grouped()[cat.id] ?? []} + {#if catPages.length > 0} + + {/if} + {/each} + + {@const uncatPages = grouped()['uncategorized'] ?? []} + {#if uncatPages.length > 0} + + {/if} + {/if} +
+ + +
+ +
+

Tags

+ {#if data.allTags.length === 0} +

No tags yet.

+ {:else} +
+ {#each data.allTags as tag} + + {tag.name} {tag.count} + + {/each} +
+ {/if} +
+ + +
+
+

Categories

+ +
+ + {#if showNewCategory} +
+ + +
+ {/if} + + {#if data.categories.length === 0} +

No categories yet.

+ {:else} +
+ {#each data.categories as cat} +
+ {cat.name} +
+ + +
+
+ {/each} +
+ {/if} +
+
+
+
diff --git a/src/routes/(app)/wiki/[slug]/+page.server.ts b/src/routes/(app)/wiki/[slug]/+page.server.ts new file mode 100644 index 0000000..5d457d6 --- /dev/null +++ b/src/routes/(app)/wiki/[slug]/+page.server.ts @@ -0,0 +1,47 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { wikiPages, wikiCategories, wikiTags, wikiPageTags } from '$lib/server/db/schema.js'; +import { eq } from 'drizzle-orm'; +import { error, redirect } from '@sveltejs/kit'; + +export const load: PageServerLoad = async ({ params }) => { + const [page] = await db + .select({ + id: wikiPages.id, + title: wikiPages.title, + slug: wikiPages.slug, + content: wikiPages.content, + categoryId: wikiPages.categoryId, + categoryName: wikiCategories.name, + categorySlug: wikiCategories.slug, + createdBy: wikiPages.createdBy, + updatedBy: wikiPages.updatedBy, + createdAt: wikiPages.createdAt, + updatedAt: wikiPages.updatedAt + }) + .from(wikiPages) + .leftJoin(wikiCategories, eq(wikiPages.categoryId, wikiCategories.id)) + .where(eq(wikiPages.slug, params.slug)); + + if (!page) error(404, 'Page not found'); + + // Get tags + const pageTags = await db + .select({ name: wikiTags.name }) + .from(wikiPageTags) + .innerJoin(wikiTags, eq(wikiPageTags.tagId, wikiTags.id)) + .where(eq(wikiPageTags.pageId, page.id)); + + return { page, tags: pageTags.map((t) => t.name) }; +}; + +export const actions: Actions = { + delete: async ({ params }) => { + const [page] = await db.select({ id: wikiPages.id }).from(wikiPages).where(eq(wikiPages.slug, params.slug)); + if (page) { + await db.delete(wikiPageTags).where(eq(wikiPageTags.pageId, page.id)); + await db.delete(wikiPages).where(eq(wikiPages.id, page.id)); + } + redirect(303, '/wiki'); + } +}; diff --git a/src/routes/(app)/wiki/[slug]/+page.svelte b/src/routes/(app)/wiki/[slug]/+page.svelte new file mode 100644 index 0000000..76c4b40 --- /dev/null +++ b/src/routes/(app)/wiki/[slug]/+page.svelte @@ -0,0 +1,82 @@ + + + + {data.page.title} - Wiki - My Collection + + +
+ +
+ Wiki + {#if data.page.categoryName} + + {data.page.categoryName} + {/if} + +
+ + +
+
+

{data.page.title}

+
+ {#each data.tags as tag} + + {tag} + + {/each} +
+
+
+ + Edit + + +
+
+ + {#if showDeleteConfirm} +
+

Are you sure you want to delete {data.page.title}?

+
+
+ +
+ +
+
+ {/if} + + +
+
+ {@html renderedContent} +
+
+ + +
+ + {#if data.page.updatedBy}Last edited by {data.page.updatedBy}{/if} + {#if data.page.updatedAt} · {formatDateTime(data.page.updatedAt)}{/if} + + + {#if data.page.createdBy}Created by {data.page.createdBy}{/if} + +
+
diff --git a/src/routes/(app)/wiki/[slug]/edit/+page.server.ts b/src/routes/(app)/wiki/[slug]/edit/+page.server.ts new file mode 100644 index 0000000..837f420 --- /dev/null +++ b/src/routes/(app)/wiki/[slug]/edit/+page.server.ts @@ -0,0 +1,80 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { wikiPages, wikiCategories, wikiTags, wikiPageTags } from '$lib/server/db/schema.js'; +import { eq } from 'drizzle-orm'; +import { error, fail, redirect } from '@sveltejs/kit'; +import { slugify } from '$lib/utils/slug.js'; + +export const load: PageServerLoad = async ({ params }) => { + const [page] = await db.select().from(wikiPages).where(eq(wikiPages.slug, params.slug)); + if (!page) error(404, 'Page not found'); + + const categories = await db.select().from(wikiCategories).orderBy(wikiCategories.name); + const existingTags = await db.select().from(wikiTags).orderBy(wikiTags.name); + + // Current tags + const pageTags = await db + .select({ name: wikiTags.name }) + .from(wikiPageTags) + .innerJoin(wikiTags, eq(wikiPageTags.tagId, wikiTags.id)) + .where(eq(wikiPageTags.pageId, page.id)); + + return { + page, + categories, + existingTags, + currentTags: pageTags.map((t) => t.name).join(', ') + }; +}; + +export const actions: Actions = { + default: async ({ request, params, locals }) => { + const formData = await request.formData(); + const title = (formData.get('title') as string)?.trim(); + const content = (formData.get('content') as string)?.trim(); + const categoryId = (formData.get('categoryId') as string) || null; + const tagsStr = (formData.get('tags') as string)?.trim(); + + if (!title) return fail(400, { error: 'Title is required' }); + if (!content) return fail(400, { error: 'Content is required' }); + + const [page] = await db.select().from(wikiPages).where(eq(wikiPages.slug, params.slug)); + if (!page) error(404, 'Page not found'); + + // Update slug if title changed + let newSlug = page.slug; + if (title !== page.title) { + newSlug = slugify(title); + const [existing] = await db.select({ id: wikiPages.id }).from(wikiPages).where(eq(wikiPages.slug, newSlug)); + if (existing && existing.id !== page.id) newSlug = `${newSlug}-${Date.now().toString(36)}`; + } + + await db + .update(wikiPages) + .set({ + title, + slug: newSlug, + content, + categoryId, + updatedBy: locals.user?.displayName ?? locals.user?.email ?? null, + updatedAt: new Date() + }) + .where(eq(wikiPages.id, page.id)); + + // Update tags — delete all and re-insert + await db.delete(wikiPageTags).where(eq(wikiPageTags.pageId, page.id)); + + if (tagsStr) { + const tagNames = tagsStr.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); + for (const tagName of tagNames) { + let [tag] = await db.select().from(wikiTags).where(eq(wikiTags.name, tagName)); + if (!tag) { + [tag] = await db.insert(wikiTags).values({ name: tagName }).returning(); + } + await db.insert(wikiPageTags).values({ pageId: page.id, tagId: tag.id }); + } + } + + redirect(303, `/wiki/${newSlug}`); + } +}; diff --git a/src/routes/(app)/wiki/[slug]/edit/+page.svelte b/src/routes/(app)/wiki/[slug]/edit/+page.svelte new file mode 100644 index 0000000..5a24c7f --- /dev/null +++ b/src/routes/(app)/wiki/[slug]/edit/+page.svelte @@ -0,0 +1,100 @@ + + + + Edit {data.page.title} - Wiki - My Collection + + +
+
+ Wiki + + {data.page.title} + Edit +
+ +

Edit Page

+ + {#if form?.error} +
{form.error}
+ {/if} + +
+
+
+ + +
+
+ + +
+
+ +
+ + + {#if data.existingTags.length > 0} +
+ {#each data.existingTags as tag} + + {/each} +
+ {/if} +
+ +
+
+ + +
+ + {#if showPreview} +
+ {@html renderedContent} +
+ + {:else} + + {/if} +
+ +
+ + Cancel +
+
+
diff --git a/src/routes/(app)/wiki/new/+page.server.ts b/src/routes/(app)/wiki/new/+page.server.ts new file mode 100644 index 0000000..0fbc950 --- /dev/null +++ b/src/routes/(app)/wiki/new/+page.server.ts @@ -0,0 +1,58 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { wikiPages, wikiCategories, wikiTags, wikiPageTags } from '$lib/server/db/schema.js'; +import { eq } from 'drizzle-orm'; +import { fail, redirect } from '@sveltejs/kit'; +import { slugify } from '$lib/utils/slug.js'; + +export const load: PageServerLoad = async () => { + const categories = await db.select().from(wikiCategories).orderBy(wikiCategories.name); + const tags = await db.select().from(wikiTags).orderBy(wikiTags.name); + return { categories, existingTags: tags }; +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + const formData = await request.formData(); + const title = (formData.get('title') as string)?.trim(); + const content = (formData.get('content') as string)?.trim(); + const categoryId = (formData.get('categoryId') as string) || null; + const tagsStr = (formData.get('tags') as string)?.trim(); + + if (!title) return fail(400, { error: 'Title is required' }); + if (!content) return fail(400, { error: 'Content is required' }); + + let slug = slugify(title); + + // Ensure unique slug + const [existing] = await db.select({ id: wikiPages.id }).from(wikiPages).where(eq(wikiPages.slug, slug)); + if (existing) slug = `${slug}-${Date.now().toString(36)}`; + + const [page] = await db + .insert(wikiPages) + .values({ + title, + slug, + content, + categoryId, + createdBy: locals.user?.displayName ?? locals.user?.email ?? null, + updatedBy: locals.user?.displayName ?? locals.user?.email ?? null + }) + .returning({ id: wikiPages.id }); + + // Handle tags + if (tagsStr && page) { + const tagNames = tagsStr.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); + + for (const tagName of tagNames) { + let [tag] = await db.select().from(wikiTags).where(eq(wikiTags.name, tagName)); + if (!tag) { + [tag] = await db.insert(wikiTags).values({ name: tagName }).returning(); + } + await db.insert(wikiPageTags).values({ pageId: page.id, tagId: tag.id }); + } + } + + redirect(303, `/wiki/${slug}`); + } +}; diff --git a/src/routes/(app)/wiki/new/+page.svelte b/src/routes/(app)/wiki/new/+page.svelte new file mode 100644 index 0000000..9f4a788 --- /dev/null +++ b/src/routes/(app)/wiki/new/+page.svelte @@ -0,0 +1,101 @@ + + + + New Wiki Page - My Collection + + +
+
+ Wiki + New Page +
+ +

New Wiki Page

+ + {#if form?.error} +
{form.error}
+ {/if} + +
+
+
+ + +
+
+ + +
+
+ +
+ + + {#if data.existingTags.length > 0} +
+ {#each data.existingTags as tag} + + {/each} +
+ {/if} +
+ + +
+
+ + +
+ + {#if showPreview} +
+ {@html renderedContent} +
+ + {:else} + + {/if} +
+ +
+ + Cancel +
+
+