feat(init): add initial project scaffolding and database schema

- Add Dockerfile for multi-stage production build
- Add Drizzle configuration and initial migration for PostgreSQL schema
- Add project configuration files (.prettierrc, .prettierignore, .dockerignore)
- Define core database tables: assets, memberships, site_settings
This commit is contained in:
2026-06-06 00:55:03 -04:00
parent e8b808925d
commit f1d25ecc79
34 changed files with 7744 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
error: unknown switch `P'
usage: git rm [-f | --force] [-n] [-r] [--cached] [--ignore-unmatch]
[--quiet] [--pathspec-from-file=<file> [--pathspec-file-nul]]
[--] [<pathspec>...]
-n, --[no-]dry-run dry run
-q, --[no-]quiet do not list removed files
--[no-]cached only remove from the index
-f, --[no-]force override the up-to-date check
-r allow recursive removal
--[no-]ignore-unmatch exit with a zero status even if nothing matched
--[no-]sparse allow updating entries outside of the sparse-checkout cone
--[no-]pathspec-from-file <file>
read pathspec from file
--[no-]pathspec-file-nul
with --pathspec-from-file, pathspec elements are separated with NUL character
+13
View File
@@ -0,0 +1,13 @@
node_modules/
.git/
.svelte-kit/
drizzle/
docs/
.roomodes
.roo/
.env
.env.*
*.md
.gitignore
.DS_Store
Thumbs.db
+5
View File
@@ -0,0 +1,5 @@
.svelte-kit/
build/
node_modules/
drizzle/
package-lock.json
+15
View File
@@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+27
View File
@@ -0,0 +1,27 @@
# ---- Build Stage ----
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
# ---- Production Stage ----
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/package.json /app/package-lock.json ./
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node", "build/index.js"]
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!
}
});
+93
View File
@@ -0,0 +1,93 @@
CREATE TYPE "public"."role" AS ENUM('owner', 'admin', 'editor');--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "assets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"site_id" uuid NOT NULL,
"uploaded_by_user_id" uuid,
"type" text NOT NULL,
"filename" text NOT NULL,
"mime_type" text,
"size" integer,
"cdn_key" text NOT NULL,
"alt_text" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "memberships" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"site_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"role" "role" NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "site_settings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"site_id" uuid NOT NULL,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "site_settings_site_id_unique" UNIQUE("site_id")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "sites" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sites_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"discord_id" text NOT NULL,
"discord_username" text,
"discord_avatar" text,
"email" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_login_at" timestamp with time zone,
CONSTRAINT "users_discord_id_unique" UNIQUE("discord_id")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "assets" ADD CONSTRAINT "assets_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "assets" ADD CONSTRAINT "assets_uploaded_by_user_id_users_id_fk" FOREIGN KEY ("uploaded_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "site_settings" ADD CONSTRAINT "site_settings_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "assets_site_id_idx" ON "assets" USING btree ("site_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "assets_cdn_key_idx" ON "assets" USING btree ("cdn_key");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "memberships_site_user_idx" ON "memberships" USING btree ("site_id","user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "memberships_site_id_idx" ON "memberships" USING btree ("site_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "memberships_user_id_idx" ON "memberships" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "site_settings_site_id_idx" ON "site_settings" USING btree ("site_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "sites_slug_idx" ON "sites" USING btree ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "users_discord_id_idx" ON "users" USING btree ("discord_id");
+551
View File
@@ -0,0 +1,551 @@
{
"id": "a5b6b795-1a2c-4068-ba92-e80ff48fcb3a",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.assets": {
"name": "assets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"site_id": {
"name": "site_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"uploaded_by_user_id": {
"name": "uploaded_by_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"cdn_key": {
"name": "cdn_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"alt_text": {
"name": "alt_text",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"assets_site_id_idx": {
"name": "assets_site_id_idx",
"columns": [
{
"expression": "site_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"assets_cdn_key_idx": {
"name": "assets_cdn_key_idx",
"columns": [
{
"expression": "cdn_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"assets_site_id_sites_id_fk": {
"name": "assets_site_id_sites_id_fk",
"tableFrom": "assets",
"tableTo": "sites",
"columnsFrom": [
"site_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"assets_uploaded_by_user_id_users_id_fk": {
"name": "assets_uploaded_by_user_id_users_id_fk",
"tableFrom": "assets",
"tableTo": "users",
"columnsFrom": [
"uploaded_by_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.memberships": {
"name": "memberships",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"site_id": {
"name": "site_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"memberships_site_user_idx": {
"name": "memberships_site_user_idx",
"columns": [
{
"expression": "site_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"memberships_site_id_idx": {
"name": "memberships_site_id_idx",
"columns": [
{
"expression": "site_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"memberships_user_id_idx": {
"name": "memberships_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"memberships_site_id_sites_id_fk": {
"name": "memberships_site_id_sites_id_fk",
"tableFrom": "memberships",
"tableTo": "sites",
"columnsFrom": [
"site_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"memberships_user_id_users_id_fk": {
"name": "memberships_user_id_users_id_fk",
"tableFrom": "memberships",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.site_settings": {
"name": "site_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"site_id": {
"name": "site_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"site_settings_site_id_idx": {
"name": "site_settings_site_id_idx",
"columns": [
{
"expression": "site_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"site_settings_site_id_sites_id_fk": {
"name": "site_settings_site_id_sites_id_fk",
"tableFrom": "site_settings",
"tableTo": "sites",
"columnsFrom": [
"site_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"site_settings_site_id_unique": {
"name": "site_settings_site_id_unique",
"nullsNotDistinct": false,
"columns": [
"site_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sites": {
"name": "sites",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sites_slug_idx": {
"name": "sites_slug_idx",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sites_slug_unique": {
"name": "sites_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"discord_id": {
"name": "discord_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"discord_username": {
"name": "discord_username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"discord_avatar": {
"name": "discord_avatar",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"last_login_at": {
"name": "last_login_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"users_discord_id_idx": {
"name": "users_discord_id_idx",
"columns": [
{
"expression": "discord_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_discord_id_unique": {
"name": "users_discord_id_unique",
"nullsNotDistinct": false,
"columns": [
"discord_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.role": {
"name": "role",
"schema": "public",
"values": [
"owner",
"admin",
"editor"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1780717416486,
"tag": "0000_moaning_the_initiative",
"breakpoints": true
}
]
}
+33
View File
@@ -0,0 +1,33 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
/** @type {import('eslint').Linter.Config[]} */
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['build/', '.svelte-kit/', 'dist/', 'drizzle/']
}
];
+5267
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "the-collective-hub",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/lib/server/db/seed.ts"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/kit": "^2.8.1",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0",
"@types/pg": "^8.20.0",
"drizzle-kit": "^0.31.10",
"eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.46.0",
"globals": "^15.12.0",
"prettier": "^3.4.1",
"prettier-plugin-svelte": "^3.3.2",
"svelte": "^5.2.0",
"svelte-check": "^4.1.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"typescript-eslint": "^8.16.0",
"vite": "^6.0.1"
},
"dependencies": {
"better-auth": "^1.6.14",
"drizzle-orm": "^0.45.2",
"kysely": "^0.29.2",
"pg": "^8.21.0",
"postgres": "^3.4.5"
}
}
+84
View File
@@ -0,0 +1,84 @@
// Minimal seed script — reads DATABASE_URL from .env via process.env
// Works standalone without SvelteKit/Vite module resolution.
import postgres from 'postgres';
import { readFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Parse .env manually
const envPath = resolve(__dirname, '..', '.env');
const envContent = readFileSync(envPath, 'utf-8');
const env = {};
for (const line of envContent.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const [key, ...rest] = trimmed.split('=');
if (key) env[key.trim()] = rest.join('=').trim();
}
const sql = postgres(env.DATABASE_URL);
async function seed() {
console.log('🌱 Seeding database...');
const [site] = await sql`
INSERT INTO sites (slug, name, is_active)
VALUES ('local-dev', 'Local Dev Site', true)
ON CONFLICT (slug) DO NOTHING
RETURNING id
`;
if (site) {
console.log(` ✅ Created site: Local Dev Site (local-dev)`);
await sql`
INSERT INTO site_settings (site_id, settings)
VALUES (${site.id}, ${sql.json({
branding: {
siteName: 'Local Dev Site',
tagline: 'A local development site',
logoCdnKey: null,
backgroundCdnKey: null,
faviconCdnKey: null
},
theme: {
preset: 'dark',
accentColor: '#e63946',
backgroundColor: '#1a1a2e',
textColor: '#eaeaea'
},
homepage: {
heroTitle: 'Welcome',
heroSubtitle: 'This is a development site',
aboutText: '',
primaryButtonText: 'Join us on Discord',
primaryButtonLink: 'https://discord.gg/example',
showNextEvent: true,
showSchedule: true
},
layout: {
preset: 'standard'
}
})})
ON CONFLICT (site_id) DO NOTHING
`;
console.log(' ✅ Created default site settings');
} else {
console.log(' ️ Site "local-dev" already exists, skipping.');
}
console.log('🎉 Seeding complete!');
}
seed()
.catch((err) => {
console.error('❌ Seed failed:', err);
process.exit(1);
})
.finally(() => {
process.exit(0);
});
+28
View File
@@ -0,0 +1,28 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
import type { Site, Membership } from '$lib/server/db/schema';
import type { SiteSettingsData } from '$lib/shared/types';
declare global {
namespace App {
// interface Error {}
interface Locals {
site: Site | null;
siteSlug: string;
siteSettings: SiteSettingsData | null;
user: {
id: string;
discordId: string;
discordUsername: string;
discordAvatar: string | null;
email: string | null;
} | null;
membership: Membership | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-prerender="true">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
import type { Handle } from '@sveltejs/kit';
import { building } from '$app/environment';
import { env } from '$env/dynamic/private';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { auth } from '$lib/server/auth';
import { getSiteBySlug } from '$lib/server/site-resolver';
/**
* Root server hook — runs on every request.
*
* Order of operations:
* 1. Resolve the current site from SITE_SLUG env var → attach to event.locals
* 2. Delegate to Better Auth's svelteKitHandler (handles /api/auth/* routes,
* passes through for all other routes)
*/
export const handle: Handle = async ({ event, resolve }) => {
// --- Site Resolution ---
const slug = env.SITE_SLUG;
if (!slug) {
throw new Error(
'SITE_SLUG environment variable is not set. Each deployment must specify its site slug.'
);
}
const siteContext = await getSiteBySlug(slug);
event.locals.site = siteContext.site;
event.locals.siteSlug = slug;
event.locals.siteSettings = siteContext.settings;
// --- Auth (Better Auth SvelteKit handler) ---
// svelteKitHandler intercepts /api/auth/* and handles OAuth flows.
// For all other routes it calls resolve(event) transparently.
return svelteKitHandler({ event, resolve, auth, building });
};
+24
View File
@@ -0,0 +1,24 @@
import { betterAuth } from 'better-auth';
import { env } from '$env/dynamic/private';
import pg from 'pg';
const { Pool } = pg;
/**
* Dedicated pg Pool for Better Auth.
* Better Auth natively supports pg.Pool — no Kysely wrapper needed.
* Better Auth manages its own tables (user, session, account, verification).
*/
const authPool = new Pool({ connectionString: env.DATABASE_URL, max: 5 });
export const auth = betterAuth({
database: authPool,
secret: env.BETTER_AUTH_SECRET || 'better-auth-dev-secret-change-in-production',
baseURL: env.BETTER_AUTH_URL || env.PUBLIC_SITE_URL || 'http://localhost:5173',
socialProviders: {
discord: {
clientId: env.DISCORD_CLIENT_ID || 'missing-client-id',
clientSecret: env.DISCORD_CLIENT_SECRET || 'missing-client-secret'
}
}
});
+7
View File
@@ -0,0 +1,7 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { env } from '$env/dynamic/private';
const client = postgres(env.DATABASE_URL);
export const db = drizzle(client);
+153
View File
@@ -0,0 +1,153 @@
import {
pgTable,
uuid,
text,
boolean,
timestamp,
integer,
jsonb,
uniqueIndex,
index,
pgEnum
} from 'drizzle-orm/pg-core';
// ─── Enums ───────────────────────────────────────────────────────────────────
export const roleEnum = pgEnum('role', ['owner', 'admin', 'editor']);
// ─── Sites ───────────────────────────────────────────────────────────────────
export const sites = pgTable(
'sites',
{
id: uuid('id').defaultRandom().primaryKey(),
slug: text('slug').notNull().unique(),
name: text('name').notNull(),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date())
},
(table) => [
uniqueIndex('sites_slug_idx').on(table.slug)
]
);
// ─── Users ───────────────────────────────────────────────────────────────────
export const users = pgTable(
'users',
{
id: uuid('id').defaultRandom().primaryKey(),
discordId: text('discord_id').notNull().unique(),
discordUsername: text('discord_username'),
discordAvatar: text('discord_avatar'),
email: text('email'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
lastLoginAt: timestamp('last_login_at', { withTimezone: true })
},
(table) => [
uniqueIndex('users_discord_id_idx').on(table.discordId)
]
);
// ─── Memberships ─────────────────────────────────────────────────────────────
export const memberships = pgTable(
'memberships',
{
id: uuid('id').defaultRandom().primaryKey(),
siteId: uuid('site_id')
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
role: roleEnum('role').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date())
},
(table) => [
uniqueIndex('memberships_site_user_idx').on(table.siteId, table.userId),
index('memberships_site_id_idx').on(table.siteId),
index('memberships_user_id_idx').on(table.userId)
]
);
// ─── Site Settings ───────────────────────────────────────────────────────────
export const siteSettings = pgTable(
'site_settings',
{
id: uuid('id').defaultRandom().primaryKey(),
siteId: uuid('site_id')
.notNull()
.unique()
.references(() => sites.id, { onDelete: 'cascade' }),
settings: jsonb('settings').notNull().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date())
},
(table) => [
uniqueIndex('site_settings_site_id_idx').on(table.siteId)
]
);
// ─── Assets ──────────────────────────────────────────────────────────────────
export const assets = pgTable(
'assets',
{
id: uuid('id').defaultRandom().primaryKey(),
siteId: uuid('site_id')
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
uploadedByUserId: uuid('uploaded_by_user_id').references(() => users.id, {
onDelete: 'set null'
}),
type: text('type').notNull(),
filename: text('filename').notNull(),
mimeType: text('mime_type'),
size: integer('size'),
cdnKey: text('cdn_key').notNull(),
altText: text('alt_text'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date())
},
(table) => [
index('assets_site_id_idx').on(table.siteId),
index('assets_cdn_key_idx').on(table.cdnKey)
]
);
// ─── Type Exports ────────────────────────────────────────────────────────────
export type Site = typeof sites.$inferSelect;
export type NewSite = typeof sites.$inferInsert;
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Membership = typeof memberships.$inferSelect;
export type NewMembership = typeof memberships.$inferInsert;
export type SiteSetting = typeof siteSettings.$inferSelect;
export type NewSiteSetting = typeof siteSettings.$inferInsert;
export type Asset = typeof assets.$inferSelect;
export type NewAsset = typeof assets.$inferInsert;
+75
View File
@@ -0,0 +1,75 @@
import { db } from './index';
import { sites, siteSettings } from './schema';
/**
* Creates a test site record and associated settings row.
* Only intended for local development / seeding.
*/
async function seed() {
console.log('🌱 Seeding database...');
// Create a test site
const [site] = await db
.insert(sites)
.values({
slug: 'local-dev',
name: 'Local Dev Site',
isActive: true
})
.onConflictDoNothing()
.returning();
if (site) {
console.log(` ✅ Created site: ${site.name} (${site.slug})`);
// Create default site settings
await db
.insert(siteSettings)
.values({
siteId: site.id,
settings: {
branding: {
siteName: 'Local Dev Site',
tagline: 'A local development site',
logoCdnKey: null,
backgroundCdnKey: null,
faviconCdnKey: null
},
theme: {
preset: 'dark',
accentColor: '#e63946',
backgroundColor: '#1a1a2e',
textColor: '#eaeaea'
},
homepage: {
heroTitle: 'Welcome',
heroSubtitle: 'This is a development site',
aboutText: '',
primaryButtonText: 'Join us on Discord',
primaryButtonLink: 'https://discord.gg/example',
showNextEvent: true,
showSchedule: true
},
layout: {
preset: 'standard'
}
}
})
.onConflictDoNothing();
console.log(' ✅ Created default site settings');
} else {
console.log(' ️ Site "local-dev" already exists, skipping.');
}
console.log('🎉 Seeding complete!');
}
seed()
.catch((err) => {
console.error('❌ Seed failed:', err);
process.exit(1);
})
.finally(() => {
process.exit(0);
});
+31
View File
@@ -0,0 +1,31 @@
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { sites, siteSettings } from '$lib/server/db/schema';
import type { SiteContext } from '$lib/shared/types';
/**
* Load a site by its slug, including its settings.
* Throws if no site matches — this is a hard failure because
* every deployment MUST have a valid SITE_SLUG.
*/
export async function getSiteBySlug(slug: string): Promise<SiteContext> {
const [site] = await db.select().from(sites).where(eq(sites.slug, slug)).limit(1);
if (!site) {
throw new Error(
`Site not found for slug: "${slug}". Check your SITE_SLUG environment variable. ` +
'Run `npm run db:seed` to create the default "local-dev" site, or insert a matching row into the sites table.'
);
}
const [settingsRow] = await db
.select()
.from(siteSettings)
.where(eq(siteSettings.siteId, site.id))
.limit(1);
return {
site,
settings: (settingsRow?.settings as SiteContext['settings']) ?? {}
};
}
+51
View File
@@ -0,0 +1,51 @@
import type { Site } from '$lib/server/db/schema';
/** Branding configuration for a site */
export interface BrandingSettings {
siteName: string;
tagline: string;
logoCdnKey: string | null;
backgroundCdnKey: string | null;
faviconCdnKey: string | null;
}
/** Theme configuration */
export interface ThemeSettings {
preset: 'dark' | 'light' | 'custom';
accentColor: string;
backgroundColor: string;
textColor: string;
}
/** Homepage content configuration */
export interface HomepageSettings {
heroTitle: string;
heroSubtitle: string;
aboutText: string;
primaryButtonText: string;
primaryButtonLink: string;
showNextEvent: boolean;
showSchedule: boolean;
}
/** Layout configuration */
export interface LayoutSettings {
preset: 'standard';
}
/** Full site settings shape stored in siteSettings.settings JSON */
export interface SiteSettingsData {
branding: BrandingSettings;
theme: ThemeSettings;
homepage: HomepageSettings;
layout: LayoutSettings;
}
/** User role within a site */
export type UserRole = 'owner' | 'admin' | 'editor';
/** Site context loaded for every request */
export interface SiteContext {
site: Site;
settings: SiteSettingsData;
}
+130
View File
@@ -0,0 +1,130 @@
import type { LayoutServerLoad } from './$types';
import { auth } from '$lib/server/auth';
import { db } from '$lib/server/db';
import { users, memberships } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { env } from '$env/dynamic/private';
/**
* Root layout server load — runs on every page navigation.
*
* Responsibilities:
* 1. Load the Better Auth session (if any)
* 2. Sync the authenticated user to our application `users` table
* 3. Perform owner bootstrap: if user's Discord ID matches OWNER_DISCORD_ID,
* upsert a membership with role 'owner' for the current site
* 4. Load the user's membership for the current site
* 5. Return site, user, and membership data to all pages
*/
export const load: LayoutServerLoad = async (event) => {
const { site, siteSlug, siteSettings } = event.locals;
// Get session from Better Auth
const session = await auth.api.getSession({
headers: event.request.headers
});
let appUser: (typeof users.$inferSelect) | null = null;
let membership: (typeof memberships.$inferSelect) | null = null;
if (session?.user && site) {
// --- Sync application user ---
// Better Auth stores OAuth provider info in its `account` table.
// We query Better Auth's tables to get the Discord account details.
const accountList = await auth.api.listUserAccounts({
headers: event.request.headers
});
const discordAccount = accountList.find((a) => a.providerId === 'discord');
if (discordAccount) {
const now = new Date();
// Upsert into our application users table
await db
.insert(users)
.values({
discordId: discordAccount.accountId,
discordUsername: session.user.name,
discordAvatar: session.user.image ?? null,
email: session.user.email,
lastLoginAt: now
})
.onConflictDoUpdate({
target: users.discordId,
set: {
discordUsername: session.user.name,
discordAvatar: session.user.image ?? null,
email: session.user.email,
lastLoginAt: now,
updatedAt: now
}
});
// Fetch the upserted user
const [fetchedUser] = await db
.select()
.from(users)
.where(eq(users.discordId, discordAccount.accountId))
.limit(1);
appUser = fetchedUser ?? null;
// --- Owner Bootstrap ---
// If this user's Discord ID matches OWNER_DISCORD_ID, ensure they
// have an 'owner' membership for the current site.
if (appUser && discordAccount.accountId === env.OWNER_DISCORD_ID) {
await db
.insert(memberships)
.values({
siteId: site.id,
userId: appUser.id,
role: 'owner'
})
.onConflictDoUpdate({
target: [memberships.siteId, memberships.userId],
set: {
role: 'owner',
updatedAt: now
}
});
}
// --- Load Membership ---
if (appUser) {
const [memberRow] = await db
.select()
.from(memberships)
.where(
and(
eq(memberships.siteId, site.id),
eq(memberships.userId, appUser.id)
)
)
.limit(1);
membership = memberRow ?? null;
}
}
}
// Attach to locals for downstream use (e.g., admin guard)
event.locals.user = appUser
? {
id: appUser.id,
discordId: appUser.discordId,
discordUsername: appUser.discordUsername ?? session!.user.name,
discordAvatar: appUser.discordAvatar,
email: appUser.email
}
: null;
event.locals.membership = membership;
return {
site,
siteSlug,
siteSettings,
user: event.locals.user,
membership
};
};
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
{@render children()}
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
{#if data.site}
<h1>{data.site.name}</h1>
{/if}
{#if data.siteSettings?.branding?.tagline}
<p class="tagline">{data.siteSettings.branding.tagline}</p>
{/if}
{#if data.user}
<p>Welcome, {data.user.discordUsername}!</p>
{#if data.membership}
<p>Role: {data.membership.role}</p>
<a href="/admin">Admin Panel</a>
{:else}
<p>You are logged in but not a member of this site.</p>
{/if}
<a href="/api/auth/sign-out">Sign Out</a>
{:else}
<p>Your site is running. <a href="/login">Login with Discord</a> to manage it.</p>
{/if}
+66
View File
@@ -0,0 +1,66 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
/**
* Admin auth guard — runs on every /admin/* request.
*
* Checks:
* 1. User is authenticated (locals.user exists)
* 2. User has a membership for the current site
* 3. User's membership role is one of: 'owner', 'admin', 'editor'
*
* If any check fails, redirects to /login with an error message.
*
* Returns user profile data (Discord avatar URL, username) for the top bar display.
*/
export const load: LayoutServerLoad = async (event) => {
const { user, membership, site } = event.locals;
// Not authenticated
if (!user) {
throw redirect(
303,
`/login?error=${encodeURIComponent('You must be logged in to access the admin panel.')}`
);
}
// No site context (shouldn't happen if hooks.server.ts works)
if (!site) {
throw redirect(
303,
`/login?error=${encodeURIComponent('No site context found. Check your SITE_SLUG environment variable.')}`
);
}
// Not a member
if (!membership) {
throw redirect(
303,
`/login?error=${encodeURIComponent('Your account is not a member of this site. Contact the site owner for access.')}`
);
}
// Check role
const allowedRoles = ['owner', 'admin', 'editor'];
if (!allowedRoles.includes(membership.role)) {
throw redirect(
303,
`/login?error=${encodeURIComponent('You do not have permission to access the admin panel. Required roles: owner, admin, or editor.')}`
);
}
// Construct Discord avatar URL for the top bar
const discordAvatarUrl = user.discordAvatar
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.discordAvatar}.png`
: `https://cdn.discordapp.com/embed/avatars/${(parseInt(user.discordId) >> 22) % 6}.png`;
// User is authorized — return data for admin pages
return {
user: {
...user,
discordAvatarUrl
},
membership,
site
};
};
+403
View File
@@ -0,0 +1,403 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import { page } from '$app/stores';
import { createAuthClient } from 'better-auth/svelte';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
const authClient = createAuthClient();
/** Whether the mobile sidebar is open */
let sidebarOpen = $state(false);
/** Close sidebar on mobile when a nav link is clicked */
function closeSidebar() {
sidebarOpen = false;
}
async function handleLogout() {
await authClient.signOut();
window.location.href = '/';
}
/**
* Check if a nav path matches the current page.
* Dashboard ("/admin") only matches exactly; other paths match prefix.
*/
function isActive(href: string): boolean {
const current = $page.url.pathname;
if (href === '/admin') return current === '/admin';
return current.startsWith(href);
}
interface NavItem {
label: string;
href: string;
placeholder: boolean;
}
const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/admin', placeholder: false },
{ label: 'Settings', href: '/admin/settings', placeholder: false },
{ label: 'Branding', href: '/admin/branding', placeholder: true },
{ label: 'Homepage', href: '/admin/homepage', placeholder: true },
{ label: 'Links', href: '/admin/links', placeholder: true },
{ label: 'Events', href: '/admin/events', placeholder: true },
{ label: 'Assets', href: '/admin/assets', placeholder: false },
{ label: 'Team', href: '/admin/team', placeholder: true }
];
</script>
<svelte:head>
<title>Admin — {data.site?.name ?? 'The Collective Hub'}</title>
</svelte:head>
<div class="admin-layout">
<!-- Mobile overlay backdrop -->
{#if sidebarOpen}
<button class="sidebar-overlay" onclick={closeSidebar} aria-label="Close sidebar"></button>
{/if}
<!-- Left Sidebar -->
<aside class="sidebar" class:sidebar--open={sidebarOpen}>
<div class="sidebar-brand">
<a href="/admin" class="sidebar-brand-link">
<span class="sidebar-brand-icon"></span>
<span class="sidebar-brand-text">Admin</span>
</a>
</div>
<nav class="sidebar-nav">
<ul>
{#each navItems as item}
<li>
{#if item.placeholder}
<span class="nav-link nav-link--placeholder" title="Coming in a later phase">
{item.label}
<span class="placeholder-badge">soon</span>
</span>
{:else}
<a
href={item.href}
class="nav-link"
class:nav-link--active={isActive(item.href)}
onclick={closeSidebar}
>
{item.label}
</a>
{/if}
</li>
{/each}
</ul>
</nav>
<div class="sidebar-footer">
<a href="/" class="back-link">← Back to Site</a>
</div>
</aside>
<!-- Main area -->
<div class="main-area">
<!-- Top Bar -->
<header class="top-bar">
<!-- Hamburger (mobile only) -->
<button
class="hamburger"
onclick={() => (sidebarOpen = !sidebarOpen)}
aria-label="Toggle sidebar"
>
<span class="hamburger-bar"></span>
<span class="hamburger-bar"></span>
<span class="hamburger-bar"></span>
</button>
<span class="top-bar-site">{data.site?.name ?? 'Site'}</span>
<div class="top-bar-right">
{#if data.user}
<div class="user-info">
<img
src={data.user.discordAvatarUrl}
alt={data.user.discordUsername}
class="user-avatar"
width="32"
height="32"
/>
<span class="user-name">{data.user.discordUsername}</span>
</div>
{/if}
<button class="logout-btn" onclick={handleLogout}>Logout</button>
</div>
</header>
<!-- Content Area -->
<main class="content">
{@render children()}
</main>
</div>
</div>
<style>
/* ── Reset & Base ───────────────────────────────────────────── */
.admin-layout {
display: flex;
min-height: 100vh;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f7;
color: #1a1a2e;
}
/* ── Sidebar ────────────────────────────────────────────────── */
.sidebar {
width: 240px;
min-width: 240px;
background: #161b22;
color: #c9d1d9;
display: flex;
flex-direction: column;
border-right: 1px solid #30363d;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 30;
transition: transform 0.25s ease;
}
.sidebar-brand {
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid #21262d;
}
.sidebar-brand-link {
display: flex;
align-items: center;
gap: 0.625rem;
text-decoration: none;
color: #f0f6fc;
font-weight: 700;
font-size: 1.1rem;
letter-spacing: 0.02em;
}
.sidebar-brand-icon {
font-size: 1.3rem;
}
.sidebar-nav {
flex: 1;
padding: 0.75rem 0;
overflow-y: auto;
}
.sidebar-nav ul {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar-nav li {
margin: 0;
}
.nav-link {
display: block;
padding: 0.625rem 1.5rem;
color: #8b949e;
text-decoration: none;
font-size: 0.925rem;
font-weight: 500;
transition: background 0.15s, color 0.15s;
border-left: 3px solid transparent;
}
.nav-link:hover {
background: #1c2129;
color: #f0f6fc;
}
.nav-link--active {
background: #1c2129;
color: #f0f6fc;
border-left-color: #58a6ff;
}
/* Placeholder nav items */
.nav-link--placeholder {
cursor: not-allowed;
opacity: 0.5;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-link--placeholder:hover {
background: transparent;
color: #8b949e;
}
.placeholder-badge {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #30363d;
color: #8b949e;
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
.sidebar-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid #21262d;
}
.back-link {
color: #8b949e;
text-decoration: none;
font-size: 0.85rem;
transition: color 0.15s;
}
.back-link:hover {
color: #f0f6fc;
}
/* ── Main Area ──────────────────────────────────────────────── */
.main-area {
flex: 1;
margin-left: 240px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ── Top Bar ────────────────────────────────────────────────── */
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
background: #ffffff;
border-bottom: 1px solid #e0e0e6;
position: sticky;
top: 0;
z-index: 20;
}
.hamburger {
display: none;
flex-direction: column;
gap: 4px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
}
.hamburger-bar {
display: block;
width: 22px;
height: 2px;
background: #1a1a2e;
border-radius: 1px;
}
.top-bar-site {
font-weight: 600;
font-size: 0.95rem;
color: #555;
}
.top-bar-right {
display: flex;
align-items: center;
gap: 1rem;
}
.user-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #e0e0e6;
}
.user-name {
font-size: 0.875rem;
font-weight: 500;
color: #1a1a2e;
}
.logout-btn {
padding: 0.4rem 0.9rem;
font-size: 0.8rem;
font-weight: 500;
color: #e5534b;
background: transparent;
border: 1px solid #e5534b;
border-radius: 5px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.logout-btn:hover {
background: #e5534b;
color: #fff;
}
/* ── Content Area ───────────────────────────────────────────── */
.content {
flex: 1;
padding: 2rem;
max-width: 960px;
}
/* ── Mobile Overlay ─────────────────────────────────────────── */
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 25;
border: none;
cursor: pointer;
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar--open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
}
.main-area {
margin-left: 0;
}
.hamburger {
display: flex;
}
.content {
padding: 1.25rem;
}
.user-name {
display: none;
}
}
</style>
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>Admin Dashboard — {data.site?.name ?? 'The Collective Hub'}</title>
</svelte:head>
<h2>Admin Dashboard</h2>
<p>Welcome, {data.user?.discordUsername}! Your role is <strong>{data.membership?.role}</strong>.</p>
<p>This is a placeholder — full admin pages will be added in upcoming steps.</p>
+97
View File
@@ -0,0 +1,97 @@
import { db } from '$lib/server/db';
import { siteSettings } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { SiteSettingsData } from '$lib/shared/types';
/**
* Load current site name and tagline from the siteSettings JSON blob.
* Falls back to the site's base name and empty tagline if no settings exist yet.
*/
export const load: PageServerLoad = async (event) => {
const { site } = event.locals;
if (!site) {
return { siteName: '', tagline: '' };
}
const [row] = await db
.select({ settings: siteSettings.settings })
.from(siteSettings)
.where(eq(siteSettings.siteId, site.id))
.limit(1);
const settings = (row?.settings ?? {}) as Partial<SiteSettingsData>;
const branding = settings.branding;
return {
siteName: branding?.siteName || site.name,
tagline: branding?.tagline || ''
};
};
/**
* Form action: saves site name and tagline into the siteSettings JSON blob.
* Preserves all other settings keys (theme, homepage, layout) that may not exist yet.
*/
export const actions: Actions = {
default: async (event) => {
const { site } = event.locals;
if (!site) {
return { success: false, error: 'No site context found.' };
}
const formData = await event.request.formData();
const siteName = formData.get('siteName')?.toString().trim() ?? '';
const tagline = formData.get('tagline')?.toString().trim() ?? '';
// Validate: site name is required
if (!siteName) {
return { success: false, error: 'Site name is required.', field: 'siteName' };
}
try {
// Read current settings to preserve other keys
const [row] = await db
.select({ settings: siteSettings.settings })
.from(siteSettings)
.where(eq(siteSettings.siteId, site.id))
.limit(1);
const currentSettings = (row?.settings ?? {}) as Record<string, unknown>;
const currentBranding = (currentSettings.branding ?? {}) as Record<string, unknown>;
// Merge: update branding.siteName and branding.tagline, preserve everything else
const updatedSettings = {
...currentSettings,
branding: {
...currentBranding,
siteName,
tagline
}
};
// Upsert into siteSettings (insert if no row exists for this site, update if it does)
await db
.insert(siteSettings)
.values({
siteId: site.id,
settings: updatedSettings
})
.onConflictDoUpdate({
target: siteSettings.siteId,
set: {
settings: updatedSettings,
updatedAt: new Date()
}
});
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save settings.';
return { success: false, error: message };
}
}
};
+265
View File
@@ -0,0 +1,265 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
import { enhance } from '$app/forms';
let { data, form }: { data: PageData; form: ActionData } = $props();
/** Whether the form is currently being submitted */
let saving = $state(false);
/** Feedback message shown after save attempt */
let feedback = $state<{ type: 'success' | 'error'; message: string } | null>(null);
// Clear feedback when form action data changes (new submission)
$effect(() => {
if (form) {
saving = false;
if (form.success) {
feedback = { type: 'success', message: 'Settings saved.' };
} else if (form.error) {
feedback = { type: 'error', message: form.error };
}
}
});
/** Called by use:enhance before form submission */
function handleEnhance() {
saving = true;
feedback = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return ({ result }: any) => {
saving = false;
if (result.type === 'success') {
// The $effect above will set feedback from form data
} else if (result.type === 'failure') {
feedback = {
type: 'error',
message: (result.data?.error as string) ?? 'Failed to save settings.'
};
} else if (result.type === 'error') {
feedback = {
type: 'error',
message: 'A network error occurred. Please try again.'
};
}
};
}
</script>
<svelte:head>
<title>Settings — {data.siteName} — Admin</title>
</svelte:head>
<div class="settings-page">
<h1 class="page-title">Settings</h1>
<p class="page-desc">Manage your site's basic identity.</p>
<!-- Feedback message -->
{#if feedback}
<div
class="feedback"
class:feedback--success={feedback.type === 'success'}
class:feedback--error={feedback.type === 'error'}
role="alert"
>
{#if feedback.type === 'success'}
{:else}
{/if}
{feedback.message}
</div>
{/if}
<form method="POST" use:enhance={handleEnhance} class="settings-form">
<fieldset disabled={saving}>
<!-- Site Name -->
<div class="form-group">
<label for="siteName" class="form-label">
Site Name <span class="required">*</span>
</label>
<input
type="text"
id="siteName"
name="siteName"
class="form-input"
value={data.siteName}
required
maxlength={100}
placeholder="My Collective Site"
/>
<p class="form-help">The name displayed across your site and in browser tabs.</p>
</div>
<!-- Tagline -->
<div class="form-group">
<label for="tagline" class="form-label">Tagline</label>
<input
type="text"
id="tagline"
name="tagline"
class="form-input"
value={data.tagline}
maxlength={200}
placeholder="A community for…"
/>
<p class="form-help">A short description shown beneath your site name. Optional.</p>
</div>
<!-- Submit -->
<div class="form-actions">
<button type="submit" class="save-btn" disabled={saving}>
{#if saving}
<span class="spinner"></span>
Saving…
{:else}
Save Settings
{/if}
</button>
</div>
</fieldset>
</form>
</div>
<style>
.settings-page {
max-width: 560px;
}
.page-title {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 0.25rem;
color: #1a1a2e;
}
.page-desc {
color: #666;
margin: 0 0 1.5rem;
font-size: 0.925rem;
}
/* ── Feedback ───────────────────────────────────────────────── */
.feedback {
padding: 0.7rem 1rem;
border-radius: 6px;
margin-bottom: 1.25rem;
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.feedback--success {
background: #daf5e0;
color: #1a6b30;
border: 1px solid #a3d9b1;
}
.feedback--error {
background: #fde8e8;
color: #9b1c1c;
border: 1px solid #f4b2b2;
}
/* ── Form ───────────────────────────────────────────────────── */
.settings-form fieldset {
border: none;
padding: 0;
margin: 0;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.35rem;
color: #1a1a2e;
}
.required {
color: #e5534b;
}
.form-input {
width: 100%;
padding: 0.6rem 0.75rem;
font-size: 0.925rem;
border: 1px solid #d0d0d6;
border-radius: 6px;
background: #fff;
color: #1a1a2e;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #58a6ff;
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.2);
}
.form-input:disabled {
background: #f5f5f7;
color: #999;
}
.form-help {
font-size: 0.8rem;
color: #888;
margin: 0.3rem 0 0;
}
/* ── Actions ────────────────────────────────────────────────── */
.form-actions {
margin-top: 1.75rem;
padding-top: 1.25rem;
border-top: 1px solid #e0e0e6;
}
.save-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1.5rem;
font-size: 0.9rem;
font-weight: 600;
color: #fff;
background: #1a7f37;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.save-btn:hover {
background: #14682c;
}
.save-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
/* Simple CSS spinner */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import { createAuthClient } from 'better-auth/svelte';
const authClient = createAuthClient();
async function handleDiscordLogin() {
await authClient.signIn.social({
provider: 'discord',
callbackURL: '/'
});
}
</script>
<svelte:head>
<title>Login — The Collective Hub</title>
</svelte:head>
<div class="login-container">
<h1>Login</h1>
<p>Sign in to access the admin panel and manage your site.</p>
<button class="discord-btn" onclick={handleDiscordLogin}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 127.14 96.36"
width="24"
height="24"
fill="currentColor"
>
<path
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
/>
</svg>
Login with Discord
</button>
<p class="help-text">
You must be an approved member of this site to access the admin panel.
</p>
</div>
<style>
.login-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
padding: 2rem;
font-family: system-ui, sans-serif;
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
p {
color: #888;
margin-bottom: 1.5rem;
max-width: 400px;
}
.discord-btn {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 2rem;
background: #5865f2;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.discord-btn:hover {
background: #4752c4;
}
.discord-btn:active {
background: #3c45a5;
}
.help-text {
font-size: 0.85rem;
color: #666;
margin-top: 2rem;
}
</style>
+17
View File
@@ -0,0 +1,17 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
alias: {
$lib: './src/lib',
$shared: './src/lib/shared'
}
}
};
export default config;
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"module": "esnext",
"target": "esnext",
"types": ["node"]
},
"include": [
".svelte-kit/ambient.d.ts",
".svelte-kit/non-ambient.d.ts",
".svelte-kit/types/**/$types.d.ts",
"vite.config.ts",
"src/**/*.ts",
"src/**/*.svelte",
"drizzle.config.ts"
],
"exclude": [
"node_modules",
".svelte-kit/[!ambient]*/**"
]
}
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});