Add Wiki feature with Markdown, categories, tags, and search
Deploy to LXC / deploy (push) Failing after 4s
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:
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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>›</span>
|
||||
<a href="/wiki?tag=" class="hover:text-blue-600 dark:hover:text-blue-400">{data.page.categoryName}</a>
|
||||
{/if}
|
||||
<span>›</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>›</span>
|
||||
<a href="/wiki/{data.page.slug}" class="hover:text-blue-600 dark:hover:text-blue-400">{data.page.title}</a>
|
||||
<span>›</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>
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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>›</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 Write your guide in Markdown... ## Steps 1. First step 2. Second step **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>
|
||||
Reference in New Issue
Block a user