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