You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

245 lines
6.4 KiB

Nexo desktop-first redesign + demo restructure Move servers/dating → demos/dating/server and the dating route's co-located _components/_lib/_design assets → demos/dating/web. Single `npm run dating:dev` boots both server and web via concurrently. Replace the warm-paper / raspberry-pink Claude Design adaptation with a sober desktop-first system: - Tokens: slate neutrals + violet accent, Inter family, dark mode via prefers-color-scheme + explicit data-theme. - Inline SVG icon set (Icon.svelte, Feather-style) — no Unicode glyph placeholders. - Layout shell: 240px rail nav on desktop, bottom tab bar on mobile; auth + onboarding own their own shell. - Discover: square photo carousel (dots + chevrons), name/age/location overlay, action buttons floating off the photo edge, sticky context panel on desktop. - Matches: clean conversation list with unread dot + chevron-on-hover. - Chat: 3-column on desktop (threads + thread + match context), 2-col on tablet, single thread on mobile. Day separators, retry/dismiss on failed messages, autogrow composer with Enter-to-send. - Profile: sticky hero card + 3-section form, dirty/saved indicator. - Photos: dropzone + grid with hover-only cell toolbar (set primary, reorder, delete). - Onboarding: sidebar stepper (320px) + content panel. - Auth: split layout (form left, brand quote right) on ≥900px. Server boot: env loader's repo-root resolution corrected after the move (../../.. instead of ../..) so .env.local at the repo root is picked up again. Hoist BRAND.md and SECURITY.md into docs/, drop superseded audit notes that were already closed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 months ago
import { config } from './env.mjs';
export class ApiError extends Error {
constructor(status, code, message, details) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.details = details;
}
}
export function ok(body, options = {}) {
return {
status: options.status || 200,
headers: options.headers || {},
cookies: options.cookies || [],
body
};
}
export function created(body, options = {}) {
return ok(body, { ...options, status: 201 });
}
export function noContent(options = {}) {
return {
status: 204,
headers: options.headers || {},
cookies: options.cookies || [],
body: undefined
};
}
export function fail(status, code, message, details) {
throw new ApiError(status, code, message, details);
}
export async function readJson(request) {
const text = await request.text();
if (!text.trim()) return {};
try {
const value = JSON.parse(text);
if (!value || typeof value !== 'object' || Array.isArray(value)) {
fail(400, 'invalid_body', 'Body must be a JSON object.');
}
return value;
} catch (error) {
if (error instanceof ApiError) throw error;
fail(400, 'invalid_json', 'Body must be valid JSON.');
}
}
export async function readFormData(request) {
try {
return await request.formData();
} catch {
fail(
400,
'invalid_multipart',
'Multipart form-data body is invalid. When sending FormData from the browser, do not set Content-Type manually.'
);
}
}
export function text(value, fallback = '') {
return typeof value === 'string' ? value.trim() : fallback;
}
export function optionalText(value, max = 0) {
const next = text(value);
if (!next) return '';
return max > 0 ? next.slice(0, max) : next;
}
export function bool(value, fallback = false) {
return typeof value === 'boolean' ? value : fallback;
}
export function int(value, fallback, { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = {}) {
if (value == null || value === '') return fallback;
const next = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(next)) return fallback;
return Math.min(max, Math.max(min, next));
}
export function stringArray(value, maxItems = 50, maxLength = 80) {
if (!Array.isArray(value)) return [];
return value
.map((item) => text(item))
.filter(Boolean)
.slice(0, maxItems)
.map((item) => item.slice(0, maxLength));
}
export function requireString(value, name, max = 0) {
const next = optionalText(value, max);
if (!next) fail(400, 'missing_field', `${name} is required.`, { field: name });
return next;
}
export function parseCookies(header) {
const cookies = new Map();
if (!header) return cookies;
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
const name = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (name) cookies.set(name, decodeURIComponent(value));
}
return cookies;
}
export function sessionCookie(token) {
return serializeCookie(config.cookieName, token, {
httpOnly: true,
sameSite: 'Lax',
secure: config.secureCookies,
path: '/',
maxAge: 60 * 60 * 24 * 7
});
}
export function clearSessionCookie() {
return serializeCookie(config.cookieName, '', {
httpOnly: true,
sameSite: 'Lax',
secure: config.secureCookies,
path: '/',
maxAge: 0
});
}
export function getBearerToken(request) {
const authorization = request.headers.get('authorization') || '';
if (!authorization.toLowerCase().startsWith('bearer ')) return '';
return authorization.slice(7).trim();
}
export function getSessionToken(request) {
return getBearerToken(request) || parseCookies(request.headers.get('cookie')).get(config.cookieName) || '';
}
export function handleError(error) {
if (error instanceof ApiError) {
return ok(
{
ok: false,
error: {
code: error.code,
message: error.message,
details: error.details
}
},
{ status: error.status }
);
}
if (error?.name === 'PocketBaseError') {
return ok(
{
ok: false,
error: {
code: error.code || 'pocketbase_error',
message: error.message,
details: error.data
}
},
{ status: error.status || 502 }
);
}
console.error(error);
return ok(
{
ok: false,
error: {
code: 'internal_error',
message: 'Unexpected dating server error.'
}
},
{ status: 500 }
);
}
export function applyCors(request, response) {
const origin = request.headers.get('origin');
const headers = { ...response.headers };
if (origin && config.allowedOrigins.includes(origin)) {
headers['access-control-allow-origin'] = origin;
headers['access-control-allow-credentials'] = 'true';
headers['vary'] = appendVary(headers.vary, 'Origin');
}
return { ...response, headers };
}
export function corsPreflight(request) {
const origin = request.headers.get('origin');
const headers = {
'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
'access-control-allow-headers': 'content-type,authorization',
'access-control-max-age': '600'
};
if (origin && config.allowedOrigins.includes(origin)) {
headers['access-control-allow-origin'] = origin;
headers['access-control-allow-credentials'] = 'true';
headers.vary = 'Origin';
}
return noContent({ headers });
}
export function writeResponse(nodeResponse, response) {
for (const [name, value] of Object.entries(response.headers || {})) {
if (value != null) nodeResponse.setHeader(name, value);
}
if (response.cookies?.length) {
nodeResponse.setHeader('set-cookie', response.cookies);
}
if (response.body === undefined) {
nodeResponse.writeHead(response.status);
nodeResponse.end();
return;
}
const body = JSON.stringify(response.body);
nodeResponse.setHeader('content-type', 'application/json; charset=utf-8');
nodeResponse.setHeader('content-length', Buffer.byteLength(body));
nodeResponse.writeHead(response.status);
nodeResponse.end(body);
}
function serializeCookie(name, value, options) {
const parts = [`${name}=${encodeURIComponent(value)}`];
if (options.maxAge != null) parts.push(`Max-Age=${options.maxAge}`);
if (options.path) parts.push(`Path=${options.path}`);
if (options.httpOnly) parts.push('HttpOnly');
if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);
if (options.secure) parts.push('Secure');
return parts.join('; ');
}
function appendVary(value, item) {
if (!value) return item;
const parts = value.split(',').map((part) => part.trim().toLowerCase());
return parts.includes(item.toLowerCase()) ? value : `${value}, ${item}`;
}

Powered by TurnKey Linux.