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:
Generated
+57
-1
@@ -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",
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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