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:
55
apps/web/Dockerfile
Normal file
55
apps/web/Dockerfile
Normal 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
51
apps/web/messages/en.json
Normal 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
51
apps/web/messages/es.json
Normal 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
42
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
12
apps/web/project.inlang/settings.json
Normal file
12
apps/web/project.inlang/settings.json
Normal 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
45
apps/web/src/app.css
Normal 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
16
apps/web/src/app.d.ts
vendored
Normal 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
13
apps/web/src/app.html
Normal 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
78
apps/web/src/lib/auth.ts
Normal 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
6
apps/web/src/lib/i18n.ts
Normal 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'
|
||||
});
|
||||
10
apps/web/src/lib/stores/auth.ts
Normal file
10
apps/web/src/lib/stores/auth.ts
Normal 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);
|
||||
22
apps/web/src/lib/stores/collective.ts
Normal file
22
apps/web/src/lib/stores/collective.ts
Normal 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[]>([]);
|
||||
44
apps/web/src/lib/supabase.ts
Normal file
44
apps/web/src/lib/supabase.ts
Normal 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'];
|
||||
}
|
||||
83
apps/web/src/routes/(app)/+layout.svelte
Normal file
83
apps/web/src/routes/(app)/+layout.svelte
Normal 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}
|
||||
12
apps/web/src/routes/(app)/lists/+page.svelte
Normal file
12
apps/web/src/routes/(app)/lists/+page.svelte
Normal 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>
|
||||
12
apps/web/src/routes/(app)/notes/+page.svelte
Normal file
12
apps/web/src/routes/(app)/notes/+page.svelte
Normal 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>
|
||||
12
apps/web/src/routes/(app)/search/+page.svelte
Normal file
12
apps/web/src/routes/(app)/search/+page.svelte
Normal 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>
|
||||
12
apps/web/src/routes/(app)/settings/+page.svelte
Normal file
12
apps/web/src/routes/(app)/settings/+page.svelte
Normal 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>
|
||||
12
apps/web/src/routes/(app)/tasks/+page.svelte
Normal file
12
apps/web/src/routes/(app)/tasks/+page.svelte
Normal 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>
|
||||
51
apps/web/src/routes/+layout.svelte
Normal file
51
apps/web/src/routes/+layout.svelte
Normal 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>
|
||||
16
apps/web/src/routes/+page.svelte
Normal file
16
apps/web/src/routes/+page.svelte
Normal 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 -->
|
||||
25
apps/web/src/routes/onboarding/+page.svelte
Normal file
25
apps/web/src/routes/onboarding/+page.svelte
Normal 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>
|
||||
50
apps/web/src/service-worker.ts
Normal file
50
apps/web/src/service-worker.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
11
apps/web/static/silent-check-sso.html
Normal file
11
apps/web/static/silent-check-sso.html
Normal 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
17
apps/web/svelte.config.js
Normal 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;
|
||||
20
apps/web/tailwind.config.ts
Normal file
20
apps/web/tailwind.config.ts
Normal 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
14
apps/web/tsconfig.json
Normal 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
53
apps/web/vite.config.ts
Normal 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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user