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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
.git/
|
||||
.svelte-kit/
|
||||
drizzle/
|
||||
docs/
|
||||
.roomodes
|
||||
.roo/
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
.gitignore
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,5 @@
|
||||
.svelte-kit/
|
||||
build/
|
||||
node_modules/
|
||||
drizzle/
|
||||
package-lock.json
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+27
@@ -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"]
|
||||
@@ -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!
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1780717416486,
|
||||
"tag": "0000_moaning_the_initiative",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/']
|
||||
}
|
||||
];
|
||||
Generated
+5267
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Vendored
+28
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']) ?? {}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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]*/**"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user