Add Wiki feature with Markdown, categories, tags, and search
Deploy to LXC / deploy (push) Failing after 4s

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 15:42:34 +07:00
parent 6252041631
commit 4322383ed8
14 changed files with 938 additions and 1 deletions
+57 -1
View File
@@ -11,10 +11,12 @@
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@tailwindcss/typography": "^0.5.19",
"bwip-js": "^4.9.0", "bwip-js": "^4.9.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^17.4.1", "dotenv": "^17.4.1",
"drizzle-orm": "^0.38.4", "drizzle-orm": "^0.38.4",
"marked": "^18.0.0",
"pg": "^8.13.1", "pg": "^8.13.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.33.5", "sharp": "^0.33.5",
@@ -2487,6 +2489,18 @@
"node": ">= 20" "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": { "node_modules/@tailwindcss/vite": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
@@ -2763,6 +2777,18 @@
"node": ">= 0.6" "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": { "node_modules/date-fns": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
@@ -3583,6 +3609,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -3828,6 +3866,19 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -4248,7 +4299,6 @@
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tapable": { "node_modules/tapable": {
@@ -4824,6 +4874,12 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/vite": {
"version": "6.4.1", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+2
View File
@@ -18,10 +18,12 @@
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"@tailwindcss/typography": "^0.5.19",
"bwip-js": "^4.9.0", "bwip-js": "^4.9.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^17.4.1", "dotenv": "^17.4.1",
"drizzle-orm": "^0.38.4", "drizzle-orm": "^0.38.4",
"marked": "^18.0.0",
"pg": "^8.13.1", "pg": "^8.13.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.33.5", "sharp": "^0.33.5",
+1
View File
@@ -1,3 +1,4 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin '@tailwindcss/typography';
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
+5
View File
@@ -58,6 +58,11 @@
label: 'Gallery', 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' 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', href: '/feature-requests',
label: 'Requests', label: 'Requests',
+51
View File
@@ -352,3 +352,54 @@ export const featureRequests = pgTable('feature_requests', {
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_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)
]
);
+9
View File
@@ -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);
}
+132
View File
@@ -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<string, string[]> = {};
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 };
}
};
+213
View File
@@ -0,0 +1,213 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { timeAgo } from '$lib/utils/date.js';
let { data } = $props();
let search = $state(data.filters.search ?? '');
let showNewCategory = $state(false);
function handleSearch(e: Event) {
e.preventDefault();
const url = new URL($page.url);
if (search) url.searchParams.set('q', search);
else url.searchParams.delete('q');
url.searchParams.delete('tag');
goto(url.toString());
}
// Group pages by category
const grouped = $derived(() => {
const groups: Record<string, typeof data.pages> = { uncategorized: [] };
for (const cat of data.categories) groups[cat.id] = [];
for (const p of data.pages) {
if (p.categoryId && groups[p.categoryId]) groups[p.categoryId].push(p);
else groups['uncategorized'].push(p);
}
return groups;
});
</script>
<svelte:head>
<title>Wiki - My Collection</title>
</svelte:head>
<div class="mx-auto max-w-5xl">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Wiki</h1>
<a href="/wiki/new" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
New Page
</a>
</div>
<!-- Search + filters -->
<div class="mb-6 flex flex-wrap items-center gap-3">
<form onsubmit={handleSearch} class="flex-1">
<input type="text" bind:value={search} placeholder="Search wiki..."
class="w-full max-w-sm rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
</form>
{#if data.filters.tag}
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-500 dark:text-gray-400">Tag:</span>
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">{data.filters.tag}</span>
<a href="/wiki" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">Clear</a>
</div>
{/if}
</div>
<div class="grid gap-6 lg:grid-cols-4">
<!-- Main content -->
<div class="lg:col-span-3">
{#if data.pages.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">
{#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}
</p>
</div>
{:else if data.filters.search || data.filters.tag}
<!-- Flat results for search/tag filter -->
<div class="space-y-2">
{#each data.pages as pg}
<a href="/wiki/{pg.slug}"
class="block rounded-lg border border-gray-200 bg-white p-4 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-start justify-between">
<div>
<h3 class="font-medium text-gray-900 dark:text-white">{pg.title}</h3>
<div class="mt-1 flex flex-wrap items-center gap-2">
{#if pg.categoryName}
<span class="text-xs text-gray-400 dark:text-gray-500">{pg.categoryName}</span>
{/if}
{#each pg.tags as tag}
<span class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400">{tag}</span>
{/each}
</div>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">{timeAgo(pg.updatedAt)}</span>
</div>
</a>
{/each}
</div>
{:else}
<!-- Grouped by category -->
{#each data.categories as cat}
{@const catPages = grouped()[cat.id] ?? []}
{#if catPages.length > 0}
<div class="mb-6">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">{cat.name}</h2>
<div class="space-y-2">
{#each catPages as pg}
<a href="/wiki/{pg.slug}"
class="block rounded-lg border border-gray-200 bg-white p-4 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-start justify-between">
<div>
<h3 class="font-medium text-gray-900 dark:text-white">{pg.title}</h3>
<div class="mt-1 flex flex-wrap gap-1">
{#each pg.tags as tag}
<span class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400">{tag}</span>
{/each}
</div>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">{timeAgo(pg.updatedAt)}</span>
</div>
</a>
{/each}
</div>
</div>
{/if}
{/each}
<!-- Uncategorized -->
{@const uncatPages = grouped()['uncategorized'] ?? []}
{#if uncatPages.length > 0}
<div class="mb-6">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Uncategorized</h2>
<div class="space-y-2">
{#each uncatPages as pg}
<a href="/wiki/{pg.slug}"
class="block rounded-lg border border-gray-200 bg-white p-4 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-start justify-between">
<div>
<h3 class="font-medium text-gray-900 dark:text-white">{pg.title}</h3>
<div class="mt-1 flex flex-wrap gap-1">
{#each pg.tags as tag}
<span class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400">{tag}</span>
{/each}
</div>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">{timeAgo(pg.updatedAt)}</span>
</div>
</a>
{/each}
</div>
</div>
{/if}
{/if}
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Tags -->
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Tags</h2>
{#if data.allTags.length === 0}
<p class="text-xs text-gray-500 dark:text-gray-400">No tags yet.</p>
{:else}
<div class="flex flex-wrap gap-1">
{#each data.allTags as tag}
<a href="/wiki?tag={tag.name}"
class="rounded-full px-2 py-0.5 text-xs transition-colors
{data.filters.tag === tag.name
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'}">
{tag.name} <span class="opacity-60">{tag.count}</span>
</a>
{/each}
</div>
{/if}
</div>
<!-- Categories -->
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Categories</h2>
<button type="button" onclick={() => (showNewCategory = !showNewCategory)}
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showNewCategory ? 'Cancel' : 'Add'}
</button>
</div>
{#if showNewCategory}
<form method="POST" action="?/createCategory" use:enhance class="mb-3 flex gap-2">
<input type="text" name="name" required placeholder="Category name"
class="flex-1 rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
<button type="submit" class="rounded-md bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700">Add</button>
</form>
{/if}
{#if data.categories.length === 0}
<p class="text-xs text-gray-500 dark:text-gray-400">No categories yet.</p>
{:else}
<div class="space-y-1">
{#each data.categories as cat}
<div class="group flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">{cat.name}</span>
<form method="POST" action="?/deleteCategory" use:enhance>
<input type="hidden" name="id" value={cat.id} />
<button type="submit" class="hidden text-gray-400 hover:text-red-500 group-hover:block dark:text-gray-500" title="Delete">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</form>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
@@ -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');
}
};
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { marked } from 'marked';
import { formatDateTime } from '$lib/utils/date.js';
let { data } = $props();
let showDeleteConfirm = $state(false);
const renderedContent = $derived(marked(data.page.content) as string);
</script>
<svelte:head>
<title>{data.page.title} - Wiki - My Collection</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<!-- Breadcrumb -->
<div class="mb-2 flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
<a href="/wiki" class="hover:text-blue-600 dark:hover:text-blue-400">Wiki</a>
{#if data.page.categoryName}
<span>&rsaquo;</span>
<a href="/wiki?tag=" class="hover:text-blue-600 dark:hover:text-blue-400">{data.page.categoryName}</a>
{/if}
<span>&rsaquo;</span>
</div>
<!-- Header -->
<div class="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.page.title}</h1>
<div class="mt-2 flex flex-wrap gap-1">
{#each data.tags as tag}
<a href="/wiki?tag={tag}"
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:hover:bg-blue-900/60">
{tag}
</a>
{/each}
</div>
</div>
<div class="flex gap-2">
<a href="/wiki/{data.page.slug}/edit"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
Edit
</a>
<button type="button" onclick={() => (showDeleteConfirm = !showDeleteConfirm)}
class="rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
Delete
</button>
</div>
</div>
{#if showDeleteConfirm}
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 p-5 dark:border-red-800 dark:bg-red-900/20">
<p class="mb-3 text-sm text-red-700 dark:text-red-300">Are you sure you want to delete <strong>{data.page.title}</strong>?</p>
<div class="flex gap-2">
<form method="POST" action="?/delete" use:enhance>
<button type="submit" class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">Yes, delete</button>
</form>
<button type="button" onclick={() => (showDeleteConfirm = false)}
class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">Cancel</button>
</div>
</div>
{/if}
<!-- Content -->
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="prose prose-sm max-w-none dark:prose-invert">
{@html renderedContent}
</div>
</div>
<!-- Meta -->
<div class="mt-4 flex items-center justify-between text-xs text-gray-400 dark:text-gray-500">
<span>
{#if data.page.updatedBy}Last edited by {data.page.updatedBy}{/if}
{#if data.page.updatedAt} · {formatDateTime(data.page.updatedAt)}{/if}
</span>
<span>
{#if data.page.createdBy}Created by {data.page.createdBy}{/if}
</span>
</div>
</div>
@@ -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}`);
}
};
@@ -0,0 +1,100 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { marked } from 'marked';
let { data, form } = $props();
let content = $state(data.page.content);
let showPreview = $state(false);
const renderedContent = $derived(marked(content) as string);
</script>
<svelte:head>
<title>Edit {data.page.title} - Wiki - My Collection</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-2 text-sm text-gray-500 dark:text-gray-400">
<a href="/wiki" class="hover:text-blue-600 dark:hover:text-blue-400">Wiki</a>
<span>&rsaquo;</span>
<a href="/wiki/{data.page.slug}" class="hover:text-blue-600 dark:hover:text-blue-400">{data.page.title}</a>
<span>&rsaquo;</span> Edit
</div>
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Edit Page</h1>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
<form method="POST" use:enhance class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Title *</label>
<input type="text" id="title" name="title" required value={data.page.title}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
</div>
<div>
<label for="categoryId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Category</label>
<select id="categoryId" name="categoryId"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">No category</option>
{#each data.categories as cat}
<option value={cat.id} selected={cat.id === data.page.categoryId}>{cat.name}</option>
{/each}
</select>
</div>
</div>
<div>
<label for="tags" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</label>
<input type="text" id="tags" name="tags" value={data.currentTags}
placeholder="color-classic, bluescsi (comma-separated)"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
{#if data.existingTags.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each data.existingTags as tag}
<button type="button"
onclick={() => {
const input = document.getElementById('tags') as HTMLInputElement;
const current = input.value.split(',').map(t => t.trim()).filter(Boolean);
if (!current.includes(tag.name)) {
input.value = [...current, tag.name].join(', ');
}
}}
class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
<div>
<div class="mb-1 flex items-center justify-between">
<label for="content" class="text-sm font-medium text-gray-700 dark:text-gray-300">Content * (Markdown)</label>
<button type="button" onclick={() => (showPreview = !showPreview)}
class="rounded-md px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20">
{showPreview ? 'Editor' : 'Preview'}
</button>
</div>
{#if showPreview}
<div class="prose prose-sm dark:prose-invert min-h-[300px] max-w-none rounded-md border border-gray-300 bg-white p-4 dark:border-gray-600 dark:bg-gray-700">
{@html renderedContent}
</div>
<input type="hidden" name="content" value={content} />
{:else}
<textarea id="content" name="content" rows="20" required
bind:value={content}
class="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"></textarea>
{/if}
</div>
<div class="flex items-center gap-3">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save Changes</button>
<a href="/wiki/{data.page.slug}" class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">Cancel</a>
</div>
</form>
</div>
+58
View File
@@ -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}`);
}
};
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { marked } from 'marked';
let { data, form } = $props();
let content = $state('');
let showPreview = $state(false);
const renderedContent = $derived(marked(content) as string);
</script>
<svelte:head>
<title>New Wiki Page - My Collection</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-2 text-sm text-gray-500 dark:text-gray-400">
<a href="/wiki" class="hover:text-blue-600 dark:hover:text-blue-400">Wiki</a>
<span>&rsaquo;</span> New Page
</div>
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-white">New Wiki Page</h1>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
{/if}
<form method="POST" use:enhance class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="title" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Title *</label>
<input type="text" id="title" name="title" required
placeholder="e.g. How to install BlueSCSI in Color Classic"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
</div>
<div>
<label for="categoryId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Category</label>
<select id="categoryId" name="categoryId"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">No category</option>
{#each data.categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
</div>
<div>
<label for="tags" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</label>
<input type="text" id="tags" name="tags"
placeholder="color-classic, bluescsi, scsi (comma-separated)"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400" />
{#if data.existingTags.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each data.existingTags as tag}
<button type="button"
onclick={(e) => {
const input = document.getElementById('tags') as HTMLInputElement;
const current = input.value.split(',').map(t => t.trim()).filter(Boolean);
if (!current.includes(tag.name)) {
input.value = [...current, tag.name].join(', ');
}
}}
class="rounded-full bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
<!-- Markdown editor -->
<div>
<div class="mb-1 flex items-center justify-between">
<label for="content" class="text-sm font-medium text-gray-700 dark:text-gray-300">Content * (Markdown)</label>
<button type="button" onclick={() => (showPreview = !showPreview)}
class="rounded-md px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20">
{showPreview ? 'Editor' : 'Preview'}
</button>
</div>
{#if showPreview}
<div class="prose prose-sm dark:prose-invert min-h-[300px] max-w-none rounded-md border border-gray-300 bg-white p-4 dark:border-gray-600 dark:bg-gray-700">
{@html renderedContent}
</div>
<input type="hidden" name="content" value={content} />
{:else}
<textarea id="content" name="content" rows="15" required
bind:value={content}
class="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
placeholder="# Getting Started&#10;&#10;Write your guide in Markdown...&#10;&#10;## Steps&#10;&#10;1. First step&#10;2. Second step&#10;&#10;**Bold text** and `code` are supported."></textarea>
{/if}
</div>
<div class="flex items-center gap-3">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Create Page</button>
<a href="/wiki" class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700">Cancel</a>
</div>
</form>
</div>