Fase 0: scaffold monorepo, SvelteKit skeleton, and dev infrastructure

- Turborepo + pnpm workspaces with apps/web and packages/types
- SvelteKit app: Tailwind, Paraglide i18n (en/es), keycloak-js, Supabase client,
  auth/collective stores, PWA service worker skeleton, all route placeholders
- packages/types: domain types (User, Collective, ShoppingList, etc.) and
  database.ts placeholder for generated types
- Justfile with dev, db-*, kc-*, build, backup, restore, deploy recipes
- infra/docker-compose.dev.yml: Postgres 15, GoTrue, PostgREST, Realtime v2.83,
  Storage, Kong (port 8001), Studio, Keycloak 24
- infra/docker-compose.prod.yml, kong.yml, db-init scripts, backup/deploy scripts
- keycloak/realm-export.json with colectivo realm and 5 dev test users
- supabase/config.toml and seed.sql with sample collective and items
- GitHub Actions: ci.yml (lint+typecheck+build) and deploy.yml (GHCR + SSH)
- .env.example documenting all required variables
- Fixed docker-compose issues: Studio image tag, Kong port conflict (8001),
  internal role passwords init script, Realtime METRICS_JWT_SECRET/APP_NAME

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 14:37:51 +02:00
parent f5903ef442
commit 973f9e413c
57 changed files with 2508 additions and 1 deletions

55
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
# Multi-stage build for the SvelteKit Node.js server
# Build args map to PUBLIC_* env vars baked at build time (SvelteKit requires this)
FROM node:22-alpine AS base
RUN corepack enable pnpm
# ── Dependencies ──────────────────────────────────────────────────────────────
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY apps/web/package.json ./apps/web/
COPY packages/types/package.json ./packages/types/
RUN pnpm install --frozen-lockfile
# ── Build ─────────────────────────────────────────────────────────────────────
FROM base AS builder
WORKDIR /app
ARG PUBLIC_SUPABASE_URL
ARG PUBLIC_SUPABASE_ANON_KEY
ARG PUBLIC_KEYCLOAK_URL
ARG PUBLIC_KEYCLOAK_REALM
ARG PUBLIC_KEYCLOAK_CLIENT_ID
ARG PUBLIC_APP_URL
ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL
ENV PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY
ENV PUBLIC_KEYCLOAK_URL=$PUBLIC_KEYCLOAK_URL
ENV PUBLIC_KEYCLOAK_REALM=$PUBLIC_KEYCLOAK_REALM
ENV PUBLIC_KEYCLOAK_CLIENT_ID=$PUBLIC_KEYCLOAK_CLIENT_ID
ENV PUBLIC_APP_URL=$PUBLIC_APP_URL
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY . .
RUN pnpm --filter @colectivo/types build 2>/dev/null || true
RUN pnpm --filter @colectivo/web build
# ── Runtime ───────────────────────────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Only copy the compiled output
COPY --from=builder /app/apps/web/build ./build
COPY --from=builder /app/apps/web/package.json ./
# Install production-only deps for the Node adapter
RUN npm install --omit=dev --ignore-scripts 2>/dev/null || true
EXPOSE 3000
CMD ["node", "build"]

51
apps/web/messages/en.json Normal file
View File

@@ -0,0 +1,51 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_lists": "Lists",
"nav_tasks": "Tasks",
"nav_notes": "Notes",
"nav_search": "Search",
"nav_settings": "Settings",
"app_name": "Colectivo",
"loading": "Loading…",
"error_generic": "Something went wrong. Please try again.",
"login_title": "Sign in to Colectivo",
"login_button": "Sign in",
"logout_button": "Sign out",
"onboarding_title": "Welcome to Colectivo",
"onboarding_create_collective": "Create a collective",
"onboarding_join_collective": "Join with an invitation link",
"collective_name_label": "Collective name",
"collective_name_placeholder": "e.g. Our Home",
"settings_title": "Settings",
"settings_display_name": "Display name",
"settings_language": "Language",
"settings_avatar": "Avatar",
"settings_save": "Save changes",
"settings_saved": "Changes saved",
"invite_member": "Invite member",
"invite_email_label": "Email address",
"invite_send": "Send invitation",
"lists_title": "Shopping Lists",
"list_add_item": "Add item…",
"list_to_buy": "To buy",
"list_checked": "Checked",
"list_finish_shopping": "Finish shopping",
"list_finish_confirm": "Mark this list as completed?",
"list_finish_confirm_yes": "Yes, finish",
"list_finish_confirm_no": "Keep shopping",
"tasks_title": "Tasks",
"notes_title": "Notes",
"search_title": "Search",
"search_placeholder": "Search lists, tasks, notes…",
"trash_restore": "Restore",
"trash_delete_permanent": "Delete permanently",
"trash_empty": "Trash is empty",
"trash_expires_in": "Expires in {days} days",
"confirm_delete": "Delete?",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"done": "Done"
}

51
apps/web/messages/es.json Normal file
View File

@@ -0,0 +1,51 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_lists": "Listas",
"nav_tasks": "Tareas",
"nav_notes": "Notas",
"nav_search": "Buscar",
"nav_settings": "Ajustes",
"app_name": "Colectivo",
"loading": "Cargando…",
"error_generic": "Algo salió mal. Por favor, inténtalo de nuevo.",
"login_title": "Accede a Colectivo",
"login_button": "Iniciar sesión",
"logout_button": "Cerrar sesión",
"onboarding_title": "Bienvenido a Colectivo",
"onboarding_create_collective": "Crear un colectivo",
"onboarding_join_collective": "Unirme con un enlace de invitación",
"collective_name_label": "Nombre del colectivo",
"collective_name_placeholder": "p. ej. Nuestro Hogar",
"settings_title": "Ajustes",
"settings_display_name": "Nombre visible",
"settings_language": "Idioma",
"settings_avatar": "Avatar",
"settings_save": "Guardar cambios",
"settings_saved": "Cambios guardados",
"invite_member": "Invitar miembro",
"invite_email_label": "Correo electrónico",
"invite_send": "Enviar invitación",
"lists_title": "Listas de la compra",
"list_add_item": "Añadir producto…",
"list_to_buy": "Por comprar",
"list_checked": "Marcado",
"list_finish_shopping": "Terminar compra",
"list_finish_confirm": "¿Marcar esta lista como completada?",
"list_finish_confirm_yes": "Sí, terminar",
"list_finish_confirm_no": "Seguir comprando",
"tasks_title": "Tareas",
"notes_title": "Notas",
"search_title": "Buscar",
"search_placeholder": "Busca listas, tareas, notas…",
"trash_restore": "Restaurar",
"trash_delete_permanent": "Eliminar definitivamente",
"trash_empty": "La papelera está vacía",
"trash_expires_in": "Expira en {days} días",
"confirm_delete": "¿Eliminar?",
"cancel": "Cancelar",
"save": "Guardar",
"delete": "Eliminar",
"edit": "Editar",
"add": "Añadir",
"done": "Listo"
}

42
apps/web/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "@colectivo/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@colectivo/types": "workspace:*",
"@inlang/paraglide-sveltekit": "^0.12.3",
"@supabase/supabase-js": "^2.46.2",
"idb": "^8.0.1",
"keycloak-js": "^26.0.7",
"svelte-dnd-action": "^0.9.51"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.15.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@vite-pwa/sveltekit": "^0.6.6",
"autoprefixer": "^10.4.20",
"eslint": "^9.18.0",
"eslint-plugin-svelte": "^2.46.1",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"svelte": "^5.17.3",
"svelte-check": "^4.1.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"workbox-window": "^7.3.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"sourceLanguageTag": "en",
"languageTags": ["en", "es"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@0.9.5/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{languageTag}.json"
}
}

45
apps/web/src/app.css Normal file
View File

@@ -0,0 +1,45 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
/* Inter font */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* Design tokens — light mode */
:root {
--background: 248 250 252; /* slate-50 */
--surface: 255 255 255; /* white */
--surface-raised: 241 245 249; /* slate-100 */
--border: 226 232 240; /* slate-200 */
--text-primary: 15 23 42; /* slate-900 */
--text-secondary: 100 116 139; /* slate-500 */
--text-muted: 148 163 184; /* slate-400 */
}
/* Design tokens — dark mode */
.dark {
--background: 15 23 42; /* slate-950 */
--surface: 30 41 59; /* slate-800 → closer to slate-900 card */
--surface-raised: 51 65 85; /* slate-700 */
--border: 51 65 85; /* slate-700 */
--text-primary: 248 250 252; /* slate-50 */
--text-secondary: 148 163 184; /* slate-400 */
--text-muted: 100 116 139; /* slate-500 */
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background-color: hsl(var(--background));
color: hsl(var(--text-primary));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

16
apps/web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
import type { KeycloakUser } from '$lib/auth';
declare global {
namespace App {
interface Locals {
user: KeycloakUser | null;
}
// interface Error {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

13
apps/web/src/app.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="%paraglide.lang%">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0f172a" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

78
apps/web/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
import Keycloak from 'keycloak-js';
import { browser } from '$app/environment';
import { PUBLIC_KEYCLOAK_URL, PUBLIC_KEYCLOAK_REALM, PUBLIC_KEYCLOAK_CLIENT_ID } from '$env/static/public';
export interface KeycloakUser {
id: string;
email: string;
displayName: string;
preferredUsername: string;
}
let keycloak: Keycloak | null = null;
export function getKeycloak(): Keycloak {
if (!keycloak) {
keycloak = new Keycloak({
url: PUBLIC_KEYCLOAK_URL,
realm: PUBLIC_KEYCLOAK_REALM,
clientId: PUBLIC_KEYCLOAK_CLIENT_ID
});
}
return keycloak;
}
export async function initAuth(): Promise<boolean> {
if (!browser) return false;
const kc = getKeycloak();
const authenticated = await kc.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
// Safari blocks third-party cookies in iframes — must be false
checkLoginIframe: false,
pkceMethod: 'S256'
});
if (authenticated) {
// Schedule proactive token refresh (60s before expiry)
scheduleTokenRefresh(kc);
}
return authenticated;
}
function scheduleTokenRefresh(kc: Keycloak): void {
setInterval(
async () => {
try {
await kc.updateToken(60);
} catch {
// Token refresh failed — redirect to login
kc.login();
}
},
30 * 1000 // check every 30s
);
}
export function getUser(kc: Keycloak): KeycloakUser | null {
if (!kc.authenticated || !kc.tokenParsed) return null;
const token = kc.tokenParsed as Record<string, unknown>;
return {
id: token['sub'] as string,
email: token['email'] as string,
displayName: (token['name'] as string) ?? (token['preferred_username'] as string),
preferredUsername: token['preferred_username'] as string
};
}
export async function login(): Promise<void> {
getKeycloak().login();
}
export async function logout(): Promise<void> {
getKeycloak().logout({ redirectUri: window.location.origin });
}

6
apps/web/src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createI18n } from '@inlang/paraglide-sveltekit';
import * as runtime from '$lib/paraglide/runtime';
export const i18n = createI18n(runtime, {
defaultLanguageTag: 'en'
});

View File

@@ -0,0 +1,10 @@
import { writable, derived } from 'svelte/store';
import type Keycloak from 'keycloak-js';
import type { KeycloakUser } from '$lib/auth';
export const keycloak = writable<Keycloak | null>(null);
export const isAuthenticated = writable<boolean>(false);
export const currentUser = writable<KeycloakUser | null>(null);
export const authLoading = writable<boolean>(true);
export const token = derived(keycloak, ($kc) => $kc?.token ?? null);

View File

@@ -0,0 +1,22 @@
import { writable } from 'svelte/store';
export interface Collective {
id: string;
name: string;
emoji: string;
created_at: string;
}
export interface CollectiveMember {
user_id: string;
collective_id: string;
role: 'admin' | 'member' | 'guest';
display_name: string;
avatar_type: 'initials' | 'emoji' | 'upload';
avatar_emoji: string | null;
avatar_url: string | null;
}
export const currentCollective = writable<Collective | null>(null);
export const collectiveMembers = writable<CollectiveMember[]>([]);
export const userCollectives = writable<Collective[]>([]);

View File

@@ -0,0 +1,44 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
import { browser } from '$app/environment';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import type { Database } from '@colectivo/types';
let supabase: SupabaseClient<Database> | null = null;
export function getSupabase(): SupabaseClient<Database> {
if (!supabase) {
supabase = createClient<Database>(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
auth: {
// Auth is handled by Keycloak — disable Supabase GoTrue auth flows
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false
},
global: {
headers: {}
}
});
}
return supabase;
}
/**
* Set the Keycloak access token on the Supabase client so RLS policies
* resolve auth.uid() to the Keycloak sub claim.
*/
export function setSupabaseToken(accessToken: string): void {
const client = getSupabase();
// @ts-expect-error — internal API to inject a custom JWT
client.rest.headers['Authorization'] = `Bearer ${accessToken}`;
if (browser) {
// Also set on the realtime client
// @ts-expect-error
client.realtime.setAuth(accessToken);
}
}
export function clearSupabaseToken(): void {
const client = getSupabase();
// @ts-expect-error
delete client.rest.headers['Authorization'];
}

View File

@@ -0,0 +1,83 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { isAuthenticated, currentUser, authLoading } from '$lib/stores/auth';
import { currentCollective } from '$lib/stores/collective';
import { logout } from '$lib/auth';
import * as m from '$lib/paraglide/messages';
const navItems = [
{ href: '/lists', label: () => m.nav_lists(), icon: '🛒' },
{ href: '/tasks', label: () => m.nav_tasks(), icon: '✓' },
{ href: '/notes', label: () => m.nav_notes(), icon: '📄' },
{ href: '/search', label: () => m.nav_search(), icon: '🔍' }
];
$: activePath = $page.url.pathname;
</script>
{#if $authLoading}
<div class="flex h-screen items-center justify-center bg-background">
<span class="text-text-secondary text-sm">{m.loading()}</span>
</div>
{:else if !$isAuthenticated}
<!-- Keycloak redirect is triggered in root layout; show nothing -->
{:else}
<div class="flex h-screen overflow-hidden bg-background">
<!-- Sidebar -->
<aside class="flex w-56 flex-shrink-0 flex-col border-r border-slate-200 bg-surface dark:border-slate-700">
<!-- Collective name -->
<div class="flex h-14 items-center gap-2 border-b border-slate-200 px-4 dark:border-slate-700">
<span class="text-xl">{$currentCollective?.emoji ?? '🏠'}</span>
<span class="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
{$currentCollective?.name ?? m.app_name()}
</span>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-2 py-3">
{#each navItems as item}
<a
href={item.href}
class="mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors
{activePath.startsWith(item.href)
? 'bg-slate-100 text-slate-900 dark:bg-slate-700 dark:text-slate-50'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-50'}"
>
<span class="text-base">{item.icon}</span>
{item.label()}
</a>
{/each}
</nav>
<!-- User + settings -->
<div class="border-t border-slate-200 p-3 dark:border-slate-700">
<div class="flex items-center justify-between">
<div class="flex min-w-0 items-center gap-2">
<!-- Avatar -->
<div
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-700 dark:bg-slate-600 dark:text-slate-200"
>
{$currentUser?.displayName?.slice(0, 2).toUpperCase() ?? '?'}
</div>
<span class="truncate text-sm font-medium text-slate-700 dark:text-slate-300">
{$currentUser?.displayName ?? ''}
</span>
</div>
<a
href="/settings"
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-700 dark:hover:text-slate-300"
aria-label={m.nav_settings()}
>
</a>
</div>
</div>
</aside>
<!-- Main content -->
<main class="flex flex-1 flex-col overflow-hidden">
<slot />
</main>
</div>
{/if}

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.lists_title()}</h1>
</header>
<div class="flex-1 overflow-y-auto p-6">
<!-- Fase 2a: shopping lists content -->
</div>
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.notes_title()}</h1>
</header>
<div class="flex-1 overflow-y-auto p-6">
<!-- Fase 3: notes content -->
</div>
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.search_title()}</h1>
</header>
<div class="flex-1 overflow-y-auto p-6">
<!-- Fase 4: search content -->
</div>
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.settings_title()}</h1>
</header>
<div class="flex-1 overflow-y-auto p-6">
<!-- Fase 1: settings content (display name, avatar, language) -->
</div>
</div>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<header class="flex h-14 items-center border-b border-slate-200 px-6 dark:border-slate-700">
<h1 class="text-base font-semibold text-slate-900 dark:text-slate-50">{m.tasks_title()}</h1>
</header>
<div class="flex-1 overflow-y-auto p-6">
<!-- Fase 3: tasks content -->
</div>
</div>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { initAuth, getKeycloak, getUser } from '$lib/auth';
import { setSupabaseToken, clearSupabaseToken } from '$lib/supabase';
import { keycloak, isAuthenticated, currentUser, authLoading } from '$lib/stores/auth';
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
import { i18n } from '$lib/i18n';
const PUBLIC_ROUTES = ['/invitation'];
onMount(async () => {
const kc = getKeycloak();
keycloak.set(kc);
const authenticated = await initAuth();
isAuthenticated.set(authenticated);
if (authenticated) {
const user = getUser(kc);
currentUser.set(user);
if (kc.token) setSupabaseToken(kc.token);
// Keep token in sync with Supabase client
kc.onTokenRefreshed = () => {
if (kc.token) setSupabaseToken(kc.token);
};
kc.onAuthLogout = () => {
clearSupabaseToken();
isAuthenticated.set(false);
currentUser.set(null);
};
}
authLoading.set(false);
// Redirect unauthenticated users unless on a public route
if (!authenticated) {
const isPublic = PUBLIC_ROUTES.some((r) => $page.url.pathname.startsWith(r));
if (!isPublic) {
kc.login();
}
}
});
</script>
<ParaglideJS {i18n}>
<slot />
</ParaglideJS>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { isAuthenticated, authLoading } from '$lib/stores/auth';
onMount(() => {
const unsubLoading = authLoading.subscribe((loading) => {
if (!loading) {
unsubLoading();
goto('/lists', { replaceState: true });
}
});
});
</script>
<!-- Redirect root to /lists — handled in onMount after auth resolves -->

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex min-h-screen flex-col items-center justify-center bg-background p-6">
<div class="w-full max-w-sm space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-50">{m.onboarding_title()}</h1>
</div>
<!-- Fase 1: collective creation / join form -->
<div class="space-y-3">
<button
class="w-full rounded-md bg-slate-900 px-4 py-3 text-sm font-medium text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200"
>
{m.onboarding_create_collective()}
</button>
<button
class="w-full rounded-md border border-slate-200 bg-surface px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
{m.onboarding_join_collective()}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,50 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { build, files, version } from '$service-worker';
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
declare const self: ServiceWorkerGlobalScope;
const CACHE_NAME = `colectivo-${version}`;
const ASSETS = [...build, ...files];
// Precache all static assets
precacheAndRoute(ASSETS.map((url) => ({ url, revision: version })));
// Cache-first for static assets (JS, CSS, fonts, icons)
registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'font' ||
request.destination === 'image',
new CacheFirst({ cacheName: `${CACHE_NAME}-assets` })
);
// Network-first for app routes (HTML navigation)
registerRoute(
new NavigationRoute(
new NetworkFirst({
cacheName: `${CACHE_NAME}-pages`,
networkTimeoutSeconds: 3
})
)
);
// Background Sync for pending offline operations
const bgSyncPlugin = new BackgroundSyncPlugin('pending-ops-queue', {
maxRetentionTime: 24 * 60 // 24 hours
});
// Listen for messages from the app
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});

View File

@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<title>Silent Check SSO</title>
</head>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>

17
apps/web/svelte.config.js Normal file
View File

@@ -0,0 +1,17 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build'
}),
alias: {
$lib: './src/lib'
}
}
};
export default config;

View File

@@ -0,0 +1,20 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif']
},
colors: {
// Semantic aliases over Tailwind slate
background: 'hsl(var(--background) / <alpha-value>)',
surface: 'hsl(var(--surface) / <alpha-value>)',
'surface-raised': 'hsl(var(--surface-raised) / <alpha-value>)'
}
}
},
plugins: []
} satisfies Config;

14
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

53
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
import { paraglide } from '@inlang/paraglide-sveltekit/vite';
export default defineConfig({
plugins: [
paraglide({
project: './project.inlang',
outdir: './src/lib/paraglide'
}),
sveltekit(),
SvelteKitPWA({
srcDir: './src',
mode: 'development',
strategies: 'injectManifest',
filename: 'service-worker.ts',
scope: '/',
base: '/',
manifest: {
name: 'Colectivo',
short_name: 'Colectivo',
description: 'Gestión colaborativa del hogar',
theme_color: '#0f172a',
background_color: '#f7f9fb',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
injectManifest: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}']
},
workbox: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}']
},
devOptions: {
enabled: true,
suppressWarnings: true,
type: 'module',
navigateFallback: '/'
}
})
],
server: {
port: 5173
}
});