auth + supabase + public-scope hook (mirrors flexsiebels gate, no API keys)
- src/lib/server/supabase.ts: getSupabaseAdmin/Anon (lazy singletons, env-driven URL) - src/lib/server/fdb.ts: schema accessor for the fdbck Postgres schema - src/lib/server/auth.ts: cookie-based JWT auth (access+refresh), Supabase getUser/refreshSession. NO API key path — fdbck has no api_keys table; if needed later, add a separate module. - src/lib/server/request-context.ts + public-scope.ts: public-scope policy gate ported from flexsiebels#59. Allowlist /api/auth/* and /api/public/* by default. - src/lib/server/response.ts + errors.ts: json/requireAuth + parseBody/handleApiError - src/hooks.server.ts: validate cookies, set locals.userId, refresh tokens, run handler inside RequestState scope, evaluatePolicy after. - src/routes/+layout.svelte: minimal naked shell (only loads feedback.css). NO sidebar/footer/bottom-nav per spec. - src/routes/+page.svelte: brief landing page + admin-login link. - src/lib/styles/feedback.css: copied verbatim from flexsiebels worktree. bun run check: 0 errors, 0 warnings.
This commit is contained in:
40
src/hooks.server.ts
Normal file
40
src/hooks.server.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { authenticateRequest, COOKIE_OPTS, ACCESS_MAX_AGE, REFRESH_MAX_AGE, ACCESS_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE } from '$lib/server/auth';
|
||||
import { runWithRequestState, type RequestState } from '$lib/server/request-context';
|
||||
import { evaluatePolicy, violationResponse } from '$lib/server/public-scope';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const auth = await authenticateRequest(event.request);
|
||||
event.locals.userId = auth.userId;
|
||||
|
||||
if (auth.refreshedTokens) {
|
||||
event.cookies.set(ACCESS_TOKEN_COOKIE, auth.refreshedTokens.access_token, {
|
||||
...COOKIE_OPTS,
|
||||
maxAge: ACCESS_MAX_AGE,
|
||||
});
|
||||
event.cookies.set(REFRESH_TOKEN_COOKIE, auth.refreshedTokens.refresh_token, {
|
||||
...COOKIE_OPTS,
|
||||
maxAge: REFRESH_MAX_AGE,
|
||||
});
|
||||
}
|
||||
|
||||
const state: RequestState = {
|
||||
pathname: event.url.pathname,
|
||||
method: event.request.method,
|
||||
userId: auth.userId,
|
||||
authChecked: false,
|
||||
dbAccessed: false,
|
||||
visibilityFiltered: false,
|
||||
};
|
||||
|
||||
const response = await runWithRequestState(state, () => resolve(event));
|
||||
|
||||
const decision = evaluatePolicy({
|
||||
pathname: event.url.pathname,
|
||||
userId: auth.userId,
|
||||
responseStatus: response.status,
|
||||
state,
|
||||
});
|
||||
|
||||
return decision.allow ? response : violationResponse(event, decision.reason);
|
||||
};
|
||||
67
src/lib/server/auth.ts
Normal file
67
src/lib/server/auth.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Cookie-based JWT auth — single-user (m). Anon Supabase client validates
|
||||
* the access token; expired access tokens are refreshed via the refresh cookie.
|
||||
*
|
||||
* No API key path here (fdbck has no api_keys table) — keep it minimal.
|
||||
*/
|
||||
import { getSupabaseAnon } from './supabase';
|
||||
|
||||
export const ACCESS_TOKEN_COOKIE = 'access_token';
|
||||
export const REFRESH_TOKEN_COOKIE = 'refresh_token';
|
||||
|
||||
/** 1-year cookie max-age — match flexsiebels pattern. */
|
||||
export const ACCESS_MAX_AGE = 365 * 24 * 60 * 60;
|
||||
export const REFRESH_MAX_AGE = 365 * 24 * 60 * 60;
|
||||
|
||||
export const COOKIE_OPTS = {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
secure: true,
|
||||
};
|
||||
|
||||
export interface AuthResult {
|
||||
userId: string | null;
|
||||
refreshedTokens?: { access_token: string; refresh_token: string };
|
||||
}
|
||||
|
||||
export async function authenticateRequest(request: Request): Promise<AuthResult> {
|
||||
const access = getCookie(request, ACCESS_TOKEN_COOKIE);
|
||||
if (access) {
|
||||
try {
|
||||
const { data } = await getSupabaseAnon().auth.getUser(access);
|
||||
if (data.user?.id) return { userId: data.user.id };
|
||||
} catch {
|
||||
// fall through to refresh
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = getCookie(request, REFRESH_TOKEN_COOKIE);
|
||||
if (!refresh) return { userId: null };
|
||||
|
||||
try {
|
||||
const { data, error } = await getSupabaseAnon().auth.refreshSession({
|
||||
refresh_token: refresh,
|
||||
});
|
||||
if (error || !data.session) return { userId: null };
|
||||
return {
|
||||
userId: data.session.user.id,
|
||||
refreshedTokens: {
|
||||
access_token: data.session.access_token,
|
||||
refresh_token: data.session.refresh_token,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { userId: null };
|
||||
}
|
||||
}
|
||||
|
||||
function getCookie(request: Request, name: string): string | null {
|
||||
const header = request.headers.get('Cookie');
|
||||
if (!header) return null;
|
||||
for (const part of header.split(';')) {
|
||||
const [k, ...rest] = part.trim().split('=');
|
||||
if (k === name) return rest.join('=');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
55
src/lib/server/errors.ts
Normal file
55
src/lib/server/errors.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number = 500,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export function badRequest(message: string, details?: unknown): Response {
|
||||
return json({ error: message, ...(details ? { details } : {}) }, { status: 400 });
|
||||
}
|
||||
|
||||
export function unauthorized(message = 'Unauthorized'): Response {
|
||||
return json({ error: message }, { status: 401 });
|
||||
}
|
||||
|
||||
export function notFound(message = 'Not found'): Response {
|
||||
return json({ error: message }, { status: 404 });
|
||||
}
|
||||
|
||||
export function serverError(message = 'Internal server error'): Response {
|
||||
return json({ error: message }, { status: 500 });
|
||||
}
|
||||
|
||||
export async function parseBody<T extends z.ZodType>(
|
||||
request: Request,
|
||||
schema: T,
|
||||
): Promise<z.infer<T>> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
throw new ApiError('Invalid JSON body', 400);
|
||||
}
|
||||
const parsed = schema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new ApiError('Validation failed', 400, parsed.error.format());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function handleApiError(err: unknown, context?: string): Response {
|
||||
if (err instanceof ApiError) {
|
||||
return json({ error: err.message, ...(err.details ? { details: err.details } : {}) }, { status: err.status });
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : 'Internal error';
|
||||
if (context) console.error(`[${context}]`, err);
|
||||
return json({ error: msg }, { status: 500 });
|
||||
}
|
||||
10
src/lib/server/fdb.ts
Normal file
10
src/lib/server/fdb.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* fdb() — schema accessor for the `fdbck` Supabase schema.
|
||||
* Mirrors the flex()/mb() helper pattern from msbls' SvelteKit projects.
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { getSupabaseAdmin } from './supabase';
|
||||
|
||||
export function fdb(): ReturnType<SupabaseClient['schema']> {
|
||||
return getSupabaseAdmin().schema('fdbck');
|
||||
}
|
||||
70
src/lib/server/public-scope.ts
Normal file
70
src/lib/server/public-scope.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Public-scope policy gate.
|
||||
*
|
||||
* For anonymous requests to /api/*, the gate enforces that the handler either:
|
||||
* - invoked requireAuth (which short-circuited to 401), OR
|
||||
* - is on the explicit allowlist (its own access mechanism), OR
|
||||
* - never touched the DB.
|
||||
*
|
||||
* Otherwise the response is replaced with a 401 — fail closed.
|
||||
*/
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import type { RequestState } from './request-context';
|
||||
|
||||
export const PUBLIC_API_ALLOWLIST: RegExp[] = [
|
||||
/^\/api\/auth(\/|$)/, // sign-in / sign-out
|
||||
/^\/api\/public(\/|$)/, // explicit public endpoints — slug-gated
|
||||
];
|
||||
|
||||
export interface PolicyDecision {
|
||||
allow: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function isApiPath(pathname: string): boolean {
|
||||
return pathname.startsWith('/api/');
|
||||
}
|
||||
|
||||
export function isAllowlisted(pathname: string): boolean {
|
||||
return PUBLIC_API_ALLOWLIST.some((re) => re.test(pathname));
|
||||
}
|
||||
|
||||
export function evaluatePolicy(args: {
|
||||
pathname: string;
|
||||
userId: string | null;
|
||||
responseStatus: number;
|
||||
state: RequestState | undefined;
|
||||
}): PolicyDecision {
|
||||
const { pathname, userId, responseStatus, state } = args;
|
||||
|
||||
if (!isApiPath(pathname)) return { allow: true, reason: 'non-api' };
|
||||
if (userId) return { allow: true, reason: 'authenticated' };
|
||||
if (isAllowlisted(pathname)) return { allow: true, reason: 'allowlisted' };
|
||||
|
||||
if (responseStatus >= 400) return { allow: true, reason: 'error-response' };
|
||||
|
||||
if (!state) return { allow: true, reason: 'no-state' };
|
||||
|
||||
if (state.visibilityFiltered) return { allow: true, reason: 'visibility-filtered' };
|
||||
if (!state.dbAccessed) return { allow: true, reason: 'no-db-access' };
|
||||
|
||||
return {
|
||||
allow: false,
|
||||
reason: 'anonymous DB access without requireAuth or visibility filter',
|
||||
};
|
||||
}
|
||||
|
||||
export function violationResponse(event: RequestEvent, reason: string): Response {
|
||||
const payload = {
|
||||
error: 'public_scope_violation',
|
||||
message: 'This endpoint requires authentication.',
|
||||
path: event.url.pathname,
|
||||
method: event.request.method,
|
||||
reason,
|
||||
};
|
||||
console.error(`[public-scope] BLOCKED ${event.request.method} ${event.url.pathname}: ${reason}`);
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
43
src/lib/server/request-context.ts
Normal file
43
src/lib/server/request-context.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Per-request scope used by the public-scope policy gate (mirrors
|
||||
* flexsiebels#59). Each /api/* request runs inside an AsyncLocalStorage
|
||||
* scope; helpers that touch the DB or perform auth checks report into the
|
||||
* scope so the hook can assert at response time that anonymous responses
|
||||
* either short-circuited via requireAuth, applied a visibility filter, or
|
||||
* never touched the DB at all.
|
||||
*/
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
export interface RequestState {
|
||||
pathname: string;
|
||||
method: string;
|
||||
userId: string | null;
|
||||
authChecked: boolean;
|
||||
dbAccessed: boolean;
|
||||
visibilityFiltered: boolean;
|
||||
}
|
||||
|
||||
const store = new AsyncLocalStorage<RequestState>();
|
||||
|
||||
export function runWithRequestState<T>(state: RequestState, fn: () => T | Promise<T>): Promise<T> {
|
||||
return Promise.resolve(store.run(state, fn));
|
||||
}
|
||||
|
||||
export function getRequestState(): RequestState | undefined {
|
||||
return store.getStore();
|
||||
}
|
||||
|
||||
export function markAuthChecked(): void {
|
||||
const s = store.getStore();
|
||||
if (s) s.authChecked = true;
|
||||
}
|
||||
|
||||
export function markDbAccessed(): void {
|
||||
const s = store.getStore();
|
||||
if (s) s.dbAccessed = true;
|
||||
}
|
||||
|
||||
export function markVisibilityFiltered(): void {
|
||||
const s = store.getStore();
|
||||
if (s) s.visibilityFiltered = true;
|
||||
}
|
||||
12
src/lib/server/response.ts
Normal file
12
src/lib/server/response.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { json as kitJson } from '@sveltejs/kit';
|
||||
import { markAuthChecked } from './request-context';
|
||||
|
||||
export function json(data: unknown, status = 200): Response {
|
||||
return kitJson(data, { status });
|
||||
}
|
||||
|
||||
export function requireAuth(userId: string | null): Response | null {
|
||||
markAuthChecked();
|
||||
if (!userId) return json({ error: 'Unauthorized' }, 401);
|
||||
return null;
|
||||
}
|
||||
35
src/lib/server/supabase.ts
Normal file
35
src/lib/server/supabase.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
function getSupabaseUrl(): string {
|
||||
const host = env.SUPABASE_HOST;
|
||||
if (host) return host.startsWith('http') ? host : `https://${host}`;
|
||||
return env.SUPABASE_URL || 'http://127.0.0.1:54321';
|
||||
}
|
||||
|
||||
let _admin: SupabaseClient | null = null;
|
||||
let _anon: SupabaseClient | null = null;
|
||||
|
||||
/** Service-role client — full access, server-side only */
|
||||
export function getSupabaseAdmin(): SupabaseClient {
|
||||
if (!_admin) {
|
||||
const key = env.SUPABASE_SERVICE_KEY;
|
||||
if (!key) throw new Error('SUPABASE_SERVICE_KEY is required');
|
||||
_admin = createClient(getSupabaseUrl(), key, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
}
|
||||
return _admin;
|
||||
}
|
||||
|
||||
/** Anon client — used to validate JWTs and run sign-in flows */
|
||||
export function getSupabaseAnon(): SupabaseClient {
|
||||
if (!_anon) {
|
||||
const key = env.SUPABASE_ANON_KEY;
|
||||
if (!key) throw new Error('SUPABASE_ANON_KEY is required');
|
||||
_anon = createClient(getSupabaseUrl(), key, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
}
|
||||
return _anon;
|
||||
}
|
||||
338
src/lib/styles/feedback.css
Normal file
338
src/lib/styles/feedback.css
Normal file
@@ -0,0 +1,338 @@
|
||||
/* Feedback feature — participant + admin shared styles (issue #63). */
|
||||
|
||||
:root {
|
||||
--fb-bg: #fafafa;
|
||||
--fb-fg: #1a1a1a;
|
||||
--fb-muted: #666;
|
||||
--fb-border: #e0e0e0;
|
||||
--fb-accent: #2563eb;
|
||||
--fb-accent-hover: #1d4ed8;
|
||||
--fb-error: #dc2626;
|
||||
--fb-success: #16a34a;
|
||||
--fb-card-bg: #fff;
|
||||
--fb-mine-bg: #eff6ff;
|
||||
--fb-hidden-bg: #f3f3f3;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--fb-bg: #0a0a0a;
|
||||
--fb-fg: #f5f5f5;
|
||||
--fb-muted: #9a9a9a;
|
||||
--fb-border: #2a2a2a;
|
||||
--fb-accent: #60a5fa;
|
||||
--fb-accent-hover: #93c5fd;
|
||||
--fb-card-bg: #161616;
|
||||
--fb-mine-bg: #1e293b;
|
||||
--fb-hidden-bg: #181818;
|
||||
}
|
||||
}
|
||||
|
||||
.fb-page {
|
||||
min-height: 100vh;
|
||||
background: var(--fb-bg);
|
||||
color: var(--fb-fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.fb-shell {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.fb-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.fb-header p {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--fb-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.fb-banner {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
background: var(--fb-hidden-bg);
|
||||
border: 1px solid var(--fb-border);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fb-banner--closed {
|
||||
background: #fef3c7;
|
||||
color: #78350f;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
.fb-banner--error {
|
||||
background: #fee2e2;
|
||||
color: #7f1d1d;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.fb-section {
|
||||
background: var(--fb-card-bg);
|
||||
border: 1px solid var(--fb-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.fb-section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fb-name-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.fb-name-row label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--fb-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fb-input,
|
||||
.fb-textarea,
|
||||
.fb-select {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--fb-border);
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
background: var(--fb-card-bg);
|
||||
color: var(--fb-fg);
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.fb-textarea {
|
||||
min-height: 4rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.fb-input:focus,
|
||||
.fb-textarea:focus,
|
||||
.fb-select:focus {
|
||||
outline: 2px solid var(--fb-accent);
|
||||
outline-offset: -1px;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.fb-question {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.fb-question__label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fb-question__required {
|
||||
color: var(--fb-error);
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
|
||||
.fb-question__help {
|
||||
font-size: 0.825rem;
|
||||
color: var(--fb-muted);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.fb-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.fb-option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid var(--fb-border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: var(--fb-card-bg);
|
||||
}
|
||||
|
||||
.fb-option-row:hover {
|
||||
border-color: var(--fb-accent);
|
||||
}
|
||||
|
||||
.fb-option-row input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fb-scale {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fb-scale__btn {
|
||||
min-width: 2.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--fb-border);
|
||||
border-radius: 6px;
|
||||
background: var(--fb-card-bg);
|
||||
color: var(--fb-fg);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fb-scale__btn--active {
|
||||
background: var(--fb-accent);
|
||||
color: white;
|
||||
border-color: var(--fb-accent);
|
||||
}
|
||||
|
||||
.fb-scale__labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fb-muted);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.fb-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--fb-accent);
|
||||
color: white;
|
||||
font-family: inherit;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.fb-btn:hover {
|
||||
background: var(--fb-accent-hover);
|
||||
}
|
||||
|
||||
.fb-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fb-btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--fb-fg);
|
||||
border-color: var(--fb-border);
|
||||
}
|
||||
|
||||
.fb-btn--ghost:hover {
|
||||
background: var(--fb-hidden-bg);
|
||||
}
|
||||
|
||||
.fb-btn--danger {
|
||||
background: var(--fb-error);
|
||||
}
|
||||
|
||||
.fb-honeypot {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
|
||||
.fb-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.fb-chat__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem;
|
||||
margin: -0.25rem;
|
||||
}
|
||||
|
||||
.fb-chat__post {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--fb-hidden-bg);
|
||||
border: 1px solid var(--fb-border);
|
||||
}
|
||||
|
||||
.fb-chat__post--mine {
|
||||
background: var(--fb-mine-bg);
|
||||
border-color: var(--fb-accent);
|
||||
}
|
||||
|
||||
.fb-chat__post--hidden {
|
||||
background: var(--fb-hidden-bg);
|
||||
color: var(--fb-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fb-chat__meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fb-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.fb-chat__name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fb-chat__body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fb-chat__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fb-chat__form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.fb-chat__empty {
|
||||
text-align: center;
|
||||
color: var(--fb-muted);
|
||||
padding: 1.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fb-foot {
|
||||
text-align: center;
|
||||
color: var(--fb-muted);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
9
src/routes/+layout.svelte
Normal file
9
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import '$lib/styles/feedback.css';
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="fb-page">
|
||||
{@render children()}
|
||||
</div>
|
||||
21
src/routes/+page.svelte
Normal file
21
src/routes/+page.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<svelte:head>
|
||||
<title>fdbck — Feedback per Link</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="fb-shell">
|
||||
<header class="fb-header">
|
||||
<h1>fdbck</h1>
|
||||
<p>
|
||||
Per-Link Feedback-Forms und Live-Chat-Masken.
|
||||
Anonym, ohne Anmeldung, nur mit langem Link.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="fb-section">
|
||||
<p>Diese Seite ist nur über persönlich geteilte Links erreichbar.</p>
|
||||
<p style="margin-top: 0.75rem;">
|
||||
<a href="/login" class="fb-btn fb-btn--ghost">Admin-Login</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user