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:
@@ -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:
|
||||
Vendored
+1
@@ -18,6 +18,7 @@ declare global {
|
||||
email: string | null;
|
||||
} | null;
|
||||
membership: Membership | null;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
|
||||
+85
-2
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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('; ')
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user