feat(hooks): add migration automation, deactivated site guard, and super admin flag

- Run database migrations on startup if `RUN_MIGRATIONS=true`
- Block public access to deactivated sites with a 503 page, allowing admin/login paths
- Add `isSuperAdmin` boolean to session user type in app.d.ts
This commit is contained in:
2026-06-06 02:02:49 -04:00
parent 23f2e06c09
commit f4245a996a
14 changed files with 1596 additions and 41 deletions
+16
View File
@@ -0,0 +1,16 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: collective-hub-db
environment:
POSTGRES_USER: hub_dev
POSTGRES_PASSWORD: hub_dev_password
POSTGRES_DB: collective_hub
ports:
- '5432:5432'
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
+1
View File
@@ -18,6 +18,7 @@ declare global {
email: string | null;
} | null;
membership: Membership | null;
isSuperAdmin: boolean;
}
// interface PageData {}
// interface PageState {}
+85 -2
View File
@@ -4,13 +4,40 @@ 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';
import { runMigrations } from '$lib/server/db/migrate';
// ─── Migration Automation ────────────────────────────────────────────────────
//
// Runs database migrations on startup if RUN_MIGRATIONS=true.
// Only the designated migration-runner deployment sets this to true.
// All other deployments (RUN_MIGRATIONS=false or unset) skip migrations.
//
// This runs as a module-level initialization before the server accepts
// any requests. Migrations are skipped entirely during the Vite build
// phase (`building` is true).
if (!building) {
const shouldRun = env.RUN_MIGRATIONS === 'true';
if (shouldRun) {
console.log('🚀 RUN_MIGRATIONS=true — running database migrations…');
// Top-level await is fine here — Node.js supports it in ES modules,
// and SvelteKit's server entry is an ES module.
await runMigrations();
} else {
console.log('⏭️ RUN_MIGRATIONS is not "true" — skipping migrations.');
}
}
// ─── Request Handler ─────────────────────────────────────────────────────────
/**
* 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,
* 1. (Startup only) Run DB migrations if RUN_MIGRATIONS=true
* 2. Resolve the current site from SITE_SLUG env var → attach to event.locals
* 3. Delegate to Better Auth's svelteKitHandler (handles /api/auth/* routes,
* passes through for all other routes)
*/
export const handle: Handle = async ({ event, resolve }) => {
@@ -27,6 +54,62 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.siteSlug = slug;
event.locals.siteSettings = siteContext.settings;
// --- Deactivated Site Guard (503) ---
// If the site is deactivated (isActive=false), block public access with a 503.
// Admin paths (/admin/*) and the login page (/login) are allowed through so
// that site owners/admins can still log in and reactivate the site.
if (event.locals.site && !event.locals.site.isActive) {
const pathname = event.url.pathname;
const isAdminPath = pathname.startsWith('/admin');
const isLoginPath = pathname.startsWith('/login');
if (!isAdminPath && !isLoginPath) {
const bg = event.locals.siteSettings?.theme?.backgroundColor ?? '#1a1a2e';
const fg = event.locals.siteSettings?.theme?.textColor ?? '#eaeaea';
const name = event.locals.site.name;
return new Response(
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Site Unavailable — ${name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: ${bg};
color: ${fg};
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
padding: 2rem;
}
.card {
max-width: 480px;
}
h1 { font-size: 1.75rem; margin-bottom: 0.75rem; font-weight: 700; }
p { font-size: 1rem; opacity: 0.7; line-height: 1.5; }
</style>
</head>
<body>
<div class="card">
<h1>Site Unavailable</h1>
<p>This site is currently deactivated. Please check back later.</p>
</div>
</body>
</html>`,
{
status: 503,
headers: { 'Content-Type': 'text/html; charset=utf-8' }
}
);
}
}
// --- Auth (Better Auth SvelteKit handler) ---
// svelteKitHandler intercepts /api/auth/* and handles OAuth flows.
// For all other routes it calls resolve(event) transparently.
+29
View File
@@ -0,0 +1,29 @@
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { db } from './index';
import { resolve } from 'path';
/**
* Run database migrations from the `./drizzle` folder.
*
* Uses drizzle-orm's migrator which reads the SQL migration files
* generated by `drizzle-kit generate` and applies them in order.
*
* This should only be called on the deployment with RUN_MIGRATIONS=true.
* All other deployments skip this entire function.
*
* Logs detailed status for observability in production.
*/
export async function runMigrations(): Promise<void> {
const migrationsFolder = resolve(process.cwd(), 'drizzle');
console.log('🔄 Running database migrations…');
console.log(` Migrations folder: ${migrationsFolder}`);
try {
await migrate(db, { migrationsFolder });
console.log('✅ Migrations complete.');
} catch (err) {
console.error('❌ Migration failed:', err);
throw err;
}
}
+28 -6
View File
@@ -9,16 +9,29 @@ 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,
* 1. Parse SUPER_ADMIN_DISCORD_IDS env var for super admin access
* 2. Load the Better Auth session (if any)
* 3. Sync the authenticated user to our application `users` table
* 4. 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
* 5. Check if user is a super admin (bypasses site-scoped membership)
* 6. Load the user's membership for the current site
* 7. Return site, user, membership, and isSuperAdmin data to all pages
*/
export const load: LayoutServerLoad = async (event) => {
const { site, siteSlug, siteSettings } = event.locals;
// ─── Super Admin IDs ────────────────────────────────────────────────────
// Parse SUPER_ADMIN_DISCORD_IDS env var (comma-separated list of Discord IDs).
// Users whose Discord ID is in this list bypass all site-scoped membership
// checks and get full admin access to any site.
const superAdminIds = (env.SUPER_ADMIN_DISCORD_IDS ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean);
let isSuperAdmin = false;
// Get session from Better Auth
const session = await auth.api.getSession({
headers: event.request.headers
@@ -70,6 +83,13 @@ export const load: LayoutServerLoad = async (event) => {
appUser = fetchedUser ?? null;
// --- Super Admin Resolution ---
// Check BEFORE owner bootstrap so super admins always get the flag
// regardless of whether they also happen to be the owner.
if (appUser && superAdminIds.includes(discordAccount.accountId)) {
isSuperAdmin = true;
}
// --- Owner Bootstrap ---
// If this user's Discord ID matches OWNER_DISCORD_ID, ensure they
// have an 'owner' membership for the current site.
@@ -119,12 +139,14 @@ export const load: LayoutServerLoad = async (event) => {
}
: null;
event.locals.membership = membership;
event.locals.isSuperAdmin = isSuperAdmin;
return {
site,
siteSlug,
siteSettings,
user: event.locals.user,
membership
membership,
isSuperAdmin
};
};
+43 -3
View File
@@ -19,22 +19,62 @@
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
// ── Theme preset defaults ──────────────────────────────────────────────────
const DARK_PRESET = {
accentColor: '#e63946',
backgroundColor: '#1a1a2e',
textColor: '#eaeaea'
} as const;
const LIGHT_PRESET = {
accentColor: '#e63946',
backgroundColor: '#ffffff',
textColor: '#1a1a2e'
} as const;
// ── Theme values (reactive via $derived — updates if data.siteSettings changes) ──
let accentColor = $derived(data.siteSettings?.theme?.accentColor || '#e63946');
let backgroundColor = $derived(data.siteSettings?.theme?.backgroundColor || '#1a1a2e');
let textColor = $derived(data.siteSettings?.theme?.textColor || '#eaeaea');
const theme = $derived(data.siteSettings?.theme);
const preset = $derived(theme?.preset ?? 'dark');
// Resolve colors based on preset + any stored overrides
let accentColor = $derived(
theme?.accentColor ||
(preset === 'light' ? LIGHT_PRESET.accentColor : DARK_PRESET.accentColor)
);
let backgroundColor = $derived(
theme?.backgroundColor ||
(preset === 'light' ? LIGHT_PRESET.backgroundColor : DARK_PRESET.backgroundColor)
);
let textColor = $derived(
theme?.textColor ||
(preset === 'light' ? LIGHT_PRESET.textColor : DARK_PRESET.textColor)
);
// Secondary text: muted version based on background luminance
let textSecondary = $derived(
hexLuminance(backgroundColor) < 0.5 ? '#b0b0b0' : '#555555'
);
// Derived card/border colors for a polished look
let cardBackground = $derived(
preset === 'light'
? 'rgba(0, 0, 0, 0.03)'
: 'rgba(255, 255, 255, 0.05)'
);
let borderColor = $derived(
preset === 'light'
? 'rgba(0, 0, 0, 0.08)'
: 'rgba(255, 255, 255, 0.08)'
);
let cssVars = $derived(
[
`--color-accent: ${accentColor}`,
`--color-background: ${backgroundColor}`,
`--color-text: ${textColor}`,
`--color-text-secondary: ${textSecondary}`,
`--color-card-background: ${cardBackground}`,
`--color-border: ${borderColor}`,
`--font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`
].join('; ')
);
+10 -1
View File
@@ -1,4 +1,5 @@
import type { PageServerLoad } from './$types';
import { getCdnUrl } from '$lib/server/cdn';
/**
* Homepage server load — flattens site settings into simple props.
@@ -34,6 +35,11 @@ export const load: PageServerLoad = async (event) => {
const showNextEvent = homepage?.showNextEvent ?? false;
const showSchedule = homepage?.showSchedule ?? false;
// Resolve branding asset CDN keys to full URLs
const logoUrl = branding?.logoCdnKey ? getCdnUrl(branding.logoCdnKey) : null;
const backgroundUrl = branding?.backgroundCdnKey ? getCdnUrl(branding.backgroundCdnKey) : null;
const faviconUrl = branding?.faviconCdnKey ? getCdnUrl(branding.faviconCdnKey) : null;
return {
site,
heroTitle,
@@ -44,6 +50,9 @@ export const load: PageServerLoad = async (event) => {
showNextEvent,
showSchedule,
user,
membership
membership,
logoUrl,
backgroundUrl,
faviconUrl
};
};
+61 -15
View File
@@ -14,6 +14,9 @@
<svelte:head>
<title>{data.heroTitle}</title>
{#if data.faviconUrl}
<link rel="icon" href={data.faviconUrl} />
{/if}
</svelte:head>
<!-- Error state: no site configured -->
@@ -24,11 +27,23 @@
<p>Run <code>npm run db:seed</code> to create the default "local-dev" site.</p>
</main>
{:else}
<!-- ═══════════════════════════════════════════ -->
<!-- HERO SECTION -->
<!-- ═══════════════════════════════════════════ -->
<section class="hero">
<!-- HERO SECTION -->
<section
class="hero"
style={data.backgroundUrl
? `background-image: url(${data.backgroundUrl}); background-size: cover; background-position: center;`
: ''}
>
<div class="hero-content">
{#if data.logoUrl}
<img
src={data.logoUrl}
alt={data.heroTitle}
class="hero-logo"
width="120"
height="120"
/>
{/if}
<h1>{data.heroTitle}</h1>
{#if data.heroSubtitle}
@@ -49,9 +64,7 @@
</div>
</section>
<!-- ═══════════════════════════════════════════ -->
<!-- ABOUT SECTION (only if aboutText is set) -->
<!-- ═══════════════════════════════════════════ -->
<!-- ABOUT SECTION (only if aboutText is set) -->
{#if data.aboutText}
<section class="about">
<div class="about-content">
@@ -61,9 +74,7 @@
</section>
{/if}
<!-- ═══════════════════════════════════════════ -->
<!-- FOOTER -->
<!-- ═══════════════════════════════════════════ -->
<!-- FOOTER -->
<footer class="footer">
<div class="footer-content">
<span class="footer-site-name">{data.site.name}</span>
@@ -76,7 +87,7 @@
{/if}
<style>
/* ── Error State ───────────────────────────── */
/* Error State */
.error-state {
display: flex;
flex-direction: column;
@@ -106,7 +117,7 @@
font-size: 0.9em;
}
/* ── Hero Section ──────────────────────────── */
/* Hero Section */
.hero {
min-height: 60vh;
display: flex;
@@ -118,11 +129,40 @@
color-mix(in srgb, var(--color-background) 85%, black) 100%
);
padding: 2rem;
position: relative;
}
/* Gradient overlay: always present; when a background image is set
the overlay dims it so text remains readable */
.hero::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
var(--color-background) 0%,
color-mix(in srgb, var(--color-background) 85%, black) 100%
);
z-index: 1;
}
.hero[style*="background-image"]::before {
opacity: 0.6;
}
.hero-content {
text-align: center;
max-width: 720px;
position: relative;
z-index: 2;
}
.hero-logo {
display: block;
margin: 0 auto 1.5rem;
border-radius: 12px;
object-fit: contain;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.25));
}
.hero-content h1 {
@@ -162,7 +202,7 @@
transform: translateY(0);
}
/* ── About Section ─────────────────────────── */
/* About Section */
.about {
padding: 4rem 2rem;
display: flex;
@@ -188,7 +228,7 @@
font-size: 1.05rem;
}
/* ── Footer ────────────────────────────────── */
/* Footer */
.footer {
border-top: 1px solid rgba(176, 176, 176, 0.2);
padding: 1.5rem 2rem;
@@ -227,7 +267,7 @@
text-decoration: underline;
}
/* ── Responsive ────────────────────────────── */
/* Responsive */
@media (max-width: 768px) {
.hero-content h1 {
font-size: 2rem;
@@ -255,5 +295,11 @@
.footer-admin-link {
margin-left: 0;
}
.hero-logo {
width: 80px;
height: 80px;
margin-bottom: 1rem;
}
}
</style>
+24 -9
View File
@@ -6,15 +6,16 @@ import type { LayoutServerLoad } from './$types';
*
* 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'
* 2. If user is a super admin → bypass all membership checks, grant full access
* 3. User has a membership for the current site
* 4. 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;
const { user, membership, site, isSuperAdmin } = event.locals;
// Not authenticated
if (!user) {
@@ -32,6 +33,24 @@ export const load: LayoutServerLoad = async (event) => {
);
}
// 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`;
// Super admins bypass all membership/role checks — grant immediate access
if (isSuperAdmin) {
return {
user: {
...user,
discordAvatarUrl
},
membership: null,
site,
isSuperAdmin: true
};
}
// Not a member
if (!membership) {
throw redirect(
@@ -49,11 +68,6 @@ export const load: LayoutServerLoad = async (event) => {
);
}
// 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: {
@@ -61,6 +75,7 @@ export const load: LayoutServerLoad = async (event) => {
discordAvatarUrl
},
membership,
site
site,
isSuperAdmin: false
};
};
+40 -1
View File
@@ -40,7 +40,7 @@
const navItems: NavItem[] = [
{ label: 'Dashboard', href: '/admin', placeholder: false },
{ label: 'Settings', href: '/admin/settings', placeholder: false },
{ label: 'Branding', href: '/admin/branding', placeholder: true },
{ label: 'Branding', href: '/admin/branding', placeholder: false },
{ label: 'Homepage', href: '/admin/homepage', placeholder: true },
{ label: 'Links', href: '/admin/links', placeholder: true },
{ label: 'Events', href: '/admin/events', placeholder: true },
@@ -94,6 +94,11 @@
<div class="sidebar-footer">
<a href="/" class="back-link">← Back to Site</a>
{#if data.isSuperAdmin}
<a href="/admin" class="back-link super-admin-link" title="View All Sites (Phase 4)">
🌐 View All Sites
</a>
{/if}
</div>
</aside>
@@ -115,6 +120,11 @@
<span class="top-bar-site">{data.site?.name ?? 'Site'}</span>
<div class="top-bar-right">
{#if data.isSuperAdmin}
<span class="super-admin-badge" title="Super Admin — full access to all sites">
🛡️ Super Admin
</span>
{/if}
{#if data.user}
<div class="user-info">
<img
@@ -250,6 +260,9 @@
.sidebar-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid #21262d;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.back-link {
@@ -263,6 +276,15 @@
color: #f0f6fc;
}
.super-admin-link {
font-size: 0.8rem;
color: #58a6ff;
}
.super-admin-link:hover {
color: #79b8ff;
}
/* ── Main Area ──────────────────────────────────────────────── */
.main-area {
flex: 1;
@@ -315,6 +337,18 @@
gap: 1rem;
}
/* Super Admin Badge */
.super-admin-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.6rem;
border-radius: 4px;
background: #fde8e8;
color: #9b1c1c;
border: 1px solid #f4b2b2;
white-space: nowrap;
}
.user-info {
display: flex;
align-items: center;
@@ -399,5 +433,10 @@
.user-name {
display: none;
}
.super-admin-badge {
font-size: 0.65rem;
padding: 0.15rem 0.4rem;
}
}
</style>
+106 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { PageData } from './$types';
import { env } from '$env/dynamic/public';
let { data }: { data: PageData } = $props();
</script>
@@ -9,5 +10,108 @@
</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>
<p class="welcome-text">Welcome, <strong>{data.user?.discordUsername}</strong>! Your role is <strong>{data.membership?.role ?? 'Super Admin'}</strong>.</p>
{#if env.PUBLIC_SITE_URL}
<p class="site-url-info">
🌐 Your site is live at <a href={env.PUBLIC_SITE_URL} target="_blank" rel="noopener noreferrer">{env.PUBLIC_SITE_URL}</a>
</p>
{/if}
<div class="quick-actions">
<h3>Quick Actions</h3>
<div class="quick-actions-grid">
<a href="/admin/branding" class="quick-action-card">
<span class="quick-action-icon">🎨</span>
<span class="quick-action-label">Edit Branding</span>
<span class="quick-action-desc">Customize your site's name, logo, and theme</span>
</a>
<a href="/admin/assets" class="quick-action-card">
<span class="quick-action-icon">📁</span>
<span class="quick-action-label">Manage Assets</span>
<span class="quick-action-desc">Upload and manage images, files, and media</span>
</a>
<a href="/admin/settings" class="quick-action-card">
<span class="quick-action-icon">⚙️</span>
<span class="quick-action-label">Site Settings</span>
<span class="quick-action-desc">Configure site-wide preferences and options</span>
</a>
</div>
</div>
<style>
.welcome-text {
font-size: 1rem;
color: var(--color-text-secondary, #555);
margin-bottom: 1rem;
}
.site-url-info {
font-size: 0.9rem;
margin-bottom: 2rem;
padding: 0.625rem 1rem;
background: var(--color-card-background, rgba(0,0,0,0.03));
border: 1px solid var(--color-border, rgba(0,0,0,0.08));
border-radius: 0.5rem;
}
.site-url-info a {
color: var(--color-accent, #58a6ff);
text-decoration: none;
font-weight: 500;
}
.site-url-info a:hover {
text-decoration: underline;
}
.quick-actions {
margin-top: 1.5rem;
}
.quick-actions h3 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.875rem;
}
.quick-action-card {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 1.125rem;
background: var(--color-card-background, rgba(0,0,0,0.03));
border: 1px solid var(--color-border, rgba(0,0,0,0.08));
border-radius: 0.5rem;
text-decoration: none;
color: var(--color-text, inherit);
transition: border-color 0.15s, background 0.15s;
}
.quick-action-card:hover {
border-color: var(--color-accent, #58a6ff);
background: var(--color-card-background, rgba(0,0,0,0.05));
}
.quick-action-icon {
font-size: 1.4rem;
}
.quick-action-label {
font-weight: 600;
font-size: 0.95rem;
}
.quick-action-desc {
font-size: 0.8rem;
color: var(--color-text-secondary, #888);
line-height: 1.35;
}
</style>
+153
View File
@@ -0,0 +1,153 @@
import { db } from '$lib/server/db';
import { siteSettings, assets } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
import { getCdnUrl } from '$lib/server/cdn';
import type { Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { SiteSettingsData } from '$lib/shared/types';
/**
* Load current branding and theme settings, plus the asset library for
* logo/background selection.
*/
export const load: PageServerLoad = async (event) => {
const { site } = event.locals;
if (!site) {
return {
branding: null,
theme: null,
assetList: []
};
}
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 ?? null;
const theme = settings.theme ?? null;
// Load assets for the logo/background picker
const assetRows = await db
.select()
.from(assets)
.where(eq(assets.siteId, site.id))
.orderBy(desc(assets.createdAt))
.limit(100);
const assetList = assetRows.map((a) => ({
id: a.id,
filename: a.filename,
cdnKey: a.cdnKey,
cdnUrl: getCdnUrl(a.cdnKey),
mimeType: a.mimeType
}));
return {
branding,
theme,
assetList
};
};
/**
* Form action: saves branding and theme settings into the siteSettings JSON blob.
* Preserves all other settings keys (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();
// --- Branding fields ---
const siteName = formData.get('siteName')?.toString().trim() ?? '';
const tagline = formData.get('tagline')?.toString().trim() ?? '';
const logoCdnKey = formData.get('logoCdnKey')?.toString().trim() || null;
const backgroundCdnKey = formData.get('backgroundCdnKey')?.toString().trim() || null;
const faviconCdnKey = formData.get('faviconCdnKey')?.toString().trim() || null;
// --- Theme fields ---
const themePreset = formData.get('themePreset')?.toString().trim() ?? 'dark';
const accentColor = formData.get('accentColor')?.toString().trim() ?? '#e63946';
const backgroundColor = formData.get('backgroundColor')?.toString().trim() ?? '#1a1a2e';
const textColor = formData.get('textColor')?.toString().trim() ?? '#eaeaea';
// Validate theme preset
const validPresets = ['dark', 'light', 'custom'];
const preset = validPresets.includes(themePreset)
? (themePreset as 'dark' | 'light' | 'custom')
: 'dark';
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>;
const currentTheme = (currentSettings.theme ?? {}) as Record<string, unknown>;
const currentHomepage = currentSettings.homepage ?? {};
const currentLayout = currentSettings.layout ?? {};
// Merge branding
const branding = {
...currentBranding,
siteName: siteName || (currentBranding.siteName as string) || site.name,
tagline,
logoCdnKey,
backgroundCdnKey,
faviconCdnKey
};
// Merge theme
const theme = {
...currentTheme,
preset,
accentColor,
backgroundColor,
textColor
};
// Build final settings object preserving homepage & layout
const updatedSettings = {
...currentSettings,
branding,
theme,
homepage: currentHomepage,
layout: currentLayout
};
// Upsert into siteSettings
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 branding settings.';
return { success: false, error: message };
}
}
};
+958
View File
@@ -0,0 +1,958 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
import { enhance } from '$app/forms';
import { page } from '$app/stores';
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);
/** Whether the logo asset picker dropdown is open */
let logoPickerOpen = $state(false);
/** Whether the background asset picker dropdown is open */
let bgPickerOpen = $state(false);
// Currently selected logo/background CDN keys (bound to form)
let logoCdnKey = $state(data.branding?.logoCdnKey ?? '');
let backgroundCdnKey = $state(data.branding?.backgroundCdnKey ?? '');
// Theme values
let themePreset = $state(data.theme?.preset ?? 'dark');
let accentColor = $state(data.theme?.accentColor ?? '#e63946');
let backgroundColor = $state(data.theme?.backgroundColor ?? '#1a1a2e');
let textColor = $state(data.theme?.textColor ?? '#eaeaea');
// Site name & tagline (from branding, so pre-fill from current settings)
let siteName = $state(data.branding?.siteName ?? $page.data.site?.name ?? '');
let tagline = $state(data.branding?.tagline ?? '');
// Clear feedback when form action data changes (new submission)
$effect(() => {
if (form) {
saving = false;
if (form.success) {
feedback = { type: 'success', message: 'Branding 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 branding settings.'
};
} else if (result.type === 'error') {
feedback = {
type: 'error',
message: 'A network error occurred. Please try again.'
};
}
};
}
interface AssetItem {
id: string;
filename: string;
cdnKey: string;
cdnUrl: string;
mimeType: string | null;
}
/** Get the CDN URL for a given CDN key from the asset list */
function getAssetUrl(cdnKey: string): string | undefined {
return (data.assetList as AssetItem[]).find((a) => a.cdnKey === cdnKey)?.cdnUrl;
}
/** Select a logo from the asset picker */
function selectLogo(cdnKey: string) {
logoCdnKey = cdnKey;
logoPickerOpen = false;
}
/** Select a background from the asset picker */
function selectBackground(cdnKey: string) {
backgroundCdnKey = cdnKey;
bgPickerOpen = false;
}
/** Handle favicon upload via the API endpoint */
async function handleFaviconUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/assets', {
method: 'POST',
body: formData
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Upload failed.' }));
feedback = { type: 'error', message: err.message ?? 'Favicon upload failed.' };
} else {
feedback = { type: 'success', message: 'Favicon uploaded. Save to apply.' };
// Reload to get the new asset in the list
window.location.reload();
}
} catch {
feedback = { type: 'error', message: 'Network error during upload.' };
} finally {
input.value = '';
}
}
/** Close pickers when clicking outside */
function handlePickerClose() {
logoPickerOpen = false;
bgPickerOpen = false;
}
</script>
<svelte:head>
<title>Branding — {$page.data.site?.name ?? 'Admin'}</title>
</svelte:head>
<!-- Click-outside backdrop for pickers -->
{#if logoPickerOpen || bgPickerOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="picker-backdrop" onclick={handlePickerClose}></div>
{/if}
<div class="branding-page">
<h1 class="page-title">Branding</h1>
<p class="page-desc">Customize your site's visual identity, logo, and theme colors.</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}
<button class="feedback-close" onclick={() => (feedback = null)}>×</button>
</div>
{/if}
<form method="POST" use:enhance={handleEnhance} class="branding-form">
<fieldset disabled={saving}>
<!-- ═══════════════════════════════════════════ -->
<!-- Logo -->
<!-- ═══════════════════════════════════════════ -->
<div class="form-group">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="form-label">Logo</label>
<div class="asset-selector">
<div class="asset-preview-box">
{#if logoCdnKey && getAssetUrl(logoCdnKey)}
<img
src={getAssetUrl(logoCdnKey)}
alt="Selected logo"
class="asset-preview-img"
/>
{:else}
<span class="asset-preview-placeholder">No logo selected</span>
{/if}
</div>
<div class="asset-selector-actions">
<button
type="button"
class="select-btn"
onclick={() => (logoPickerOpen = !logoPickerOpen)}
>
{logoCdnKey ? 'Change Logo' : 'Select from Assets'}
</button>
{#if logoCdnKey}
<button
type="button"
class="clear-btn"
onclick={() => (logoCdnKey = '')}
>
Clear
</button>
{/if}
</div>
<!-- Asset picker dropdown -->
{#if logoPickerOpen}
<div class="picker-dropdown">
{#if data.assetList.length === 0}
<p class="picker-empty">
No assets uploaded yet.
<a href="/admin/assets">Upload assets first</a>.
</p>
{:else}
<div class="picker-grid">
{#each data.assetList as asset}
<button
type="button"
class="picker-item"
class:picker-item--selected={logoCdnKey === asset.cdnKey}
onclick={() => selectLogo(asset.cdnKey)}
>
<img
src={asset.cdnUrl}
alt={asset.filename}
loading="lazy"
/>
<span class="picker-item-name">{asset.filename}</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<input type="hidden" name="logoCdnKey" value={logoCdnKey} />
<p class="form-help">
Upload images in the <a href="/admin/assets">Assets</a> page, then select one here.
Recommended: square image, PNG with transparency.
</p>
</div>
<!-- ═══════════════════════════════════════════ -->
<!-- Background Image -->
<!-- ═══════════════════════════════════════════ -->
<div class="form-group">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="form-label">Background Image</label>
<div class="asset-selector">
<div class="asset-preview-box">
{#if backgroundCdnKey && getAssetUrl(backgroundCdnKey)}
<img
src={getAssetUrl(backgroundCdnKey)}
alt="Selected background"
class="asset-preview-img"
/>
{:else}
<span class="asset-preview-placeholder">No background selected</span>
{/if}
</div>
<div class="asset-selector-actions">
<button
type="button"
class="select-btn"
onclick={() => (bgPickerOpen = !bgPickerOpen)}
>
{backgroundCdnKey ? 'Change Background' : 'Select from Assets'}
</button>
{#if backgroundCdnKey}
<button
type="button"
class="clear-btn"
onclick={() => (backgroundCdnKey = '')}
>
Clear
</button>
{/if}
</div>
<!-- Asset picker dropdown -->
{#if bgPickerOpen}
<div class="picker-dropdown">
{#if data.assetList.length === 0}
<p class="picker-empty">
No assets uploaded yet.
<a href="/admin/assets">Upload assets first</a>.
</p>
{:else}
<div class="picker-grid">
{#each data.assetList as asset}
<button
type="button"
class="picker-item"
class:picker-item--selected={backgroundCdnKey === asset.cdnKey}
onclick={() => selectBackground(asset.cdnKey)}
>
<img
src={asset.cdnUrl}
alt={asset.filename}
loading="lazy"
/>
<span class="picker-item-name">{asset.filename}</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<input type="hidden" name="backgroundCdnKey" value={backgroundCdnKey} />
<p class="form-help">
Optional full-width background image for the hero section.
</p>
</div>
<!-- ═══════════════════════════════════════════ -->
<!-- Favicon -->
<!-- ═══════════════════════════════════════════ -->
<div class="form-group">
<label class="form-label" for="favicon-upload">Favicon</label>
<div class="favicon-row">
{#if data.branding?.faviconCdnKey && getAssetUrl(data.branding.faviconCdnKey)}
<img
src={getAssetUrl(data.branding.faviconCdnKey)}
alt="Current favicon"
class="favicon-preview"
width="32"
height="32"
/>
{/if}
<input
type="file"
id="favicon-upload"
accept="image/png,image/jpeg,image/webp"
class="favicon-input"
onchange={handleFaviconUpload}
disabled={saving}
/>
</div>
<input
type="hidden"
name="faviconCdnKey"
value={data.branding?.faviconCdnKey ?? ''}
/>
<p class="form-help">
Upload a favicon image. It will be added to your asset library. Recommended: 32×32 PNG.
</p>
</div>
<!-- ═══════════════════════════════════════════ -->
<!-- Site Name & Tagline (from branding) -->
<!-- ═══════════════════════════════════════════ -->
<div class="form-group">
<label for="siteName" class="form-label">Site Name</label>
<input
type="text"
id="siteName"
name="siteName"
class="form-input"
bind:value={siteName}
maxlength={100}
placeholder="My Collective Site"
/>
</div>
<div class="form-group">
<label for="tagline" class="form-label">Tagline</label>
<input
type="text"
id="tagline"
name="tagline"
class="form-input"
bind:value={tagline}
maxlength={200}
placeholder="A community for…"
/>
</div>
<!-- ═══════════════════════════════════════════ -->
<!-- Section Divider -->
<!-- ═══════════════════════════════════════════ -->
<h2 class="section-title">Theme</h2>
<!-- Theme Preset -->
<div class="form-group">
<label for="themePreset" class="form-label">Theme Preset</label>
<select
id="themePreset"
name="themePreset"
class="form-select"
bind:value={themePreset}
>
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="custom">Custom</option>
</select>
<p class="form-help">
{#if themePreset === 'dark'}
Dark background with light text. Colors below are defaults and can be customized.
{:else if themePreset === 'light'}
Light background with dark text. Colors below are defaults and can be customized.
{:else}
Custom mode: you define all colors manually.
{/if}
</p>
</div>
<!-- Accent Color -->
<div class="form-group">
<label for="accentColor" class="form-label">Accent Color</label>
<div class="color-picker-row">
<input
type="color"
id="accentColor-picker"
class="color-picker"
bind:value={accentColor}
/>
<input
type="text"
id="accentColor"
name="accentColor"
class="form-input color-input"
bind:value={accentColor}
placeholder="#e63946"
maxlength={7}
/>
<span class="color-swatch" style="background-color: {accentColor}"></span>
</div>
<p class="form-help">Used for buttons, links, and interactive elements.</p>
</div>
<!-- Background Color -->
<div class="form-group">
<label for="backgroundColor" class="form-label">Background Color</label>
<div class="color-picker-row">
<input
type="color"
id="backgroundColor-picker"
class="color-picker"
bind:value={backgroundColor}
/>
<input
type="text"
id="backgroundColor"
name="backgroundColor"
class="form-input color-input"
bind:value={backgroundColor}
placeholder="#1a1a2e"
maxlength={7}
/>
<span class="color-swatch" style="background-color: {backgroundColor}"></span>
</div>
<p class="form-help">The main background color of the public site.</p>
</div>
<!-- Text Color -->
<div class="form-group">
<label for="textColor" class="form-label">Text Color</label>
<div class="color-picker-row">
<input
type="color"
id="textColor-picker"
class="color-picker"
bind:value={textColor}
/>
<input
type="text"
id="textColor"
name="textColor"
class="form-input color-input"
bind:value={textColor}
placeholder="#eaeaea"
maxlength={7}
/>
<span class="color-swatch" style="background-color: {textColor}"></span>
</div>
<p class="form-help">The primary text color used across the public site.</p>
</div>
<!-- ═══════════════════════════════════════════ -->
<!-- Live Preview Swatch -->
<!-- ═══════════════════════════════════════════ -->
<div class="theme-preview" style="background: {backgroundColor}; color: {textColor}">
<span class="preview-label">Live Preview</span>
<span class="preview-text">
<span style="color: {accentColor}">Accent text</span> on background
</span>
<span class="preview-button" style="background: {accentColor}; color: #fff">
Button
</span>
</div>
<!-- ═══════════════════════════════════════════ -->
<!-- Actions -->
<!-- ═══════════════════════════════════════════ -->
<div class="form-actions">
<button type="submit" class="save-btn" disabled={saving}>
{#if saving}
<span class="spinner"></span>
Saving…
{:else}
Save Branding
{/if}
</button>
<a href="/" class="preview-link" target="_blank" rel="noopener noreferrer">
↗ Preview Site
</a>
</div>
</fieldset>
</form>
</div>
<style>
.branding-page {
max-width: 640px;
}
.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;
}
.feedback-close {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: inherit;
opacity: 0.6;
padding: 0 0.25rem;
margin-left: auto;
}
.feedback-close:hover {
opacity: 1;
}
/* ── Form ───────────────────────────────────────────────────── */
.branding-form fieldset {
border: none;
padding: 0;
margin: 0;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.35rem;
color: #1a1a2e;
}
.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-select {
width: 100%;
padding: 0.6rem 0.75rem;
font-size: 0.925rem;
border: 1px solid #d0d0d6;
border-radius: 6px;
background: #fff;
color: #1a1a2e;
cursor: pointer;
box-sizing: border-box;
}
.form-select:focus {
outline: none;
border-color: #58a6ff;
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.2);
}
.form-help {
font-size: 0.8rem;
color: #888;
margin: 0.3rem 0 0;
}
.form-help a {
color: #58a6ff;
text-decoration: none;
}
.form-help a:hover {
text-decoration: underline;
}
/* ── Asset Selector ─────────────────────────────────────────── */
.asset-selector {
position: relative;
}
.asset-preview-box {
width: 100%;
height: 120px;
border: 1px dashed #d0d0d6;
border-radius: 6px;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #fafafa;
}
.asset-preview-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.asset-preview-placeholder {
color: #aaa;
font-size: 0.85rem;
}
.asset-selector-actions {
display: flex;
gap: 0.5rem;
}
.select-btn {
padding: 0.45rem 0.85rem;
font-size: 0.825rem;
font-weight: 500;
color: #1a1a2e;
background: #fff;
border: 1px solid #d0d0d6;
border-radius: 5px;
cursor: pointer;
transition: background 0.15s;
}
.select-btn:hover {
background: #f0f0f3;
}
.clear-btn {
padding: 0.45rem 0.85rem;
font-size: 0.825rem;
font-weight: 500;
color: #e5534b;
background: transparent;
border: 1px solid #e5534b;
border-radius: 5px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.clear-btn:hover {
background: #fde8e8;
}
/* ── Asset Picker Dropdown ──────────────────────────────────── */
.picker-backdrop {
position: fixed;
inset: 0;
z-index: 40;
}
.picker-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 50;
margin-top: 4px;
background: #fff;
border: 1px solid #d0d0d6;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
max-height: 280px;
overflow-y: auto;
padding: 0.75rem;
}
.picker-empty {
text-align: center;
padding: 1.5rem 0.5rem;
color: #888;
font-size: 0.85rem;
margin: 0;
}
.picker-empty a {
color: #58a6ff;
}
.picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 0.5rem;
}
.picker-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.4rem;
background: #fafafa;
border: 2px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
font-family: inherit;
}
.picker-item:hover {
border-color: #58a6ff;
background: #f0f7ff;
}
.picker-item--selected {
border-color: #1a7f37;
background: #daf5e0;
}
.picker-item img {
width: 100%;
height: 60px;
object-fit: cover;
border-radius: 3px;
}
.picker-item-name {
font-size: 0.65rem;
color: #666;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 80px;
}
/* ── Favicon ────────────────────────────────────────────────── */
.favicon-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.favicon-preview {
border-radius: 4px;
border: 1px solid #d0d0d6;
object-fit: contain;
}
.favicon-input {
font-size: 0.85rem;
}
/* ── Section Title ──────────────────────────────────────────── */
.section-title {
font-size: 1.15rem;
font-weight: 700;
color: #1a1a2e;
margin: 2rem 0 1rem;
padding-top: 1.25rem;
border-top: 1px solid #e0e0e6;
}
/* ── Color Picker ───────────────────────────────────────────── */
.color-picker-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-picker {
width: 40px;
height: 38px;
padding: 2px;
border: 1px solid #d0d0d6;
border-radius: 6px;
cursor: pointer;
background: #fff;
flex-shrink: 0;
}
.color-picker:focus {
outline: none;
border-color: #58a6ff;
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.2);
}
.color-input {
flex: 1;
max-width: 140px;
font-family: monospace;
font-size: 0.9rem;
}
.color-swatch {
width: 32px;
height: 32px;
border-radius: 6px;
border: 1px solid #d0d0d6;
flex-shrink: 0;
}
/* ── Theme Preview ──────────────────────────────────────────── */
.theme-preview {
padding: 1rem 1.25rem;
border-radius: 8px;
border: 1px solid #d0d0d6;
margin: 1.5rem 0;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.preview-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
}
.preview-text {
font-size: 0.9rem;
flex: 1;
}
.preview-button {
padding: 0.4rem 1rem;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
}
/* ── Actions ────────────────────────────────────────────────── */
.form-actions {
margin-top: 1.75rem;
padding-top: 1.25rem;
border-top: 1px solid #e0e0e6;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.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;
}
.preview-link {
font-size: 0.875rem;
font-weight: 500;
color: #58a6ff;
text-decoration: none;
transition: color 0.15s;
}
.preview-link:hover {
color: #388bfd;
text-decoration: underline;
}
/* 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);
}
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.theme-preview {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
</style>
+42 -2
View File
@@ -1,8 +1,15 @@
<script lang="ts">
import type { PageData } from './$types';
import { page } from '$app/stores';
import { createAuthClient } from 'better-auth/svelte';
let { data }: { data: PageData } = $props();
const authClient = createAuthClient();
const siteName = $derived(data.site?.name ?? 'The Collective Hub');
const errorMessage = $derived($page.url.searchParams.get('error'));
async function handleDiscordLogin() {
await authClient.signIn.social({
provider: 'discord',
@@ -12,12 +19,19 @@
</script>
<svelte:head>
<title>Login — The Collective Hub</title>
<title>Login — {siteName}</title>
</svelte:head>
<div class="login-container">
<h1>Login</h1>
<p>Sign in to access the admin panel and manage your site.</p>
<p>Sign in to manage {siteName}</p>
{#if errorMessage}
<div class="error-banner" role="alert">
<span class="error-icon"></span>
<span class="error-text">{errorMessage}</span>
</div>
{/if}
<button class="discord-btn" onclick={handleDiscordLogin}>
<svg
@@ -85,6 +99,32 @@
background: #3c45a5;
}
.error-banner {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 1.25rem;
margin-bottom: 1.25rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
color: #991b1b;
font-size: 0.9rem;
max-width: 420px;
width: 100%;
box-sizing: border-box;
}
.error-icon {
flex-shrink: 0;
font-size: 1.1rem;
}
.error-text {
text-align: left;
line-height: 1.4;
}
.help-text {
font-size: 0.85rem;
color: #666;