Merge branch 'dev' into till-dev

This commit is contained in:
Wuesteon 2025-12-16 19:17:07 +01:00
commit 285e142970
251 changed files with 9752 additions and 3942 deletions

View file

@ -12,6 +12,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
@ -23,6 +24,9 @@ COPY apps/calendar/apps/backend ./apps/calendar/apps/backend
RUN pnpm install --frozen-lockfile
# Build shared packages first
WORKDIR /app/packages/better-auth-types
RUN pnpm build
WORKDIR /app/packages/shared-errors
RUN pnpm build

View file

@ -20,6 +20,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by calendar web
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
@ -47,6 +48,9 @@ COPY apps/calendar/apps/web ./apps/calendar/apps/web
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/better-auth-types
RUN pnpm build || true
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
@ -70,6 +74,10 @@ COPY --from=builder /app/apps/calendar/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/calendar/apps/web/build ./build
COPY --from=builder /app/apps/calendar/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/calendar/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 5186
@ -82,5 +90,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5186/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,37 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
BACKEND_URL=${BACKEND_URL:-"http://localhost:3016"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
TODO_API_URL=${TODO_API_URL:-"http://localhost:3018"}
CONTACTS_API_URL=${CONTACTS_API_URL:-"http://localhost:3015"}
echo "📝 Config values:"
echo " BACKEND_URL: $BACKEND_URL"
echo " AUTH_URL: $AUTH_URL"
echo " TODO_API_URL: $TODO_API_URL"
echo " CONTACTS_API_URL: $CONTACTS_API_URL"
# Generate config.json from environment variables
cat > /app/apps/calendar/apps/web/build/client/config.json <<EOF
{
"BACKEND_URL": "${BACKEND_URL}",
"AUTH_URL": "${AUTH_URL}",
"TODO_API_URL": "${TODO_API_URL}",
"CONTACTS_API_URL": "${CONTACTS_API_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/calendar/apps/web/build/client/config.json"
cat /app/apps/calendar/apps/web/build/client/config.json
# Remove pre-compressed versions (SvelteKit serves these instead of the raw file)
rm -f /app/apps/calendar/apps/web/build/client/config.json.br
rm -f /app/apps/calendar/apps/web/build/client/config.json.gz
echo "🗑️ Removed stale pre-compressed config files"
echo "🚀 Starting Calendar web app..."
exec "$@"

View file

@ -54,7 +54,8 @@
"lucide-svelte": "^0.559.0",
"suncalc": "^1.9.0",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.1"
"svelte-i18n": "^4.0.1",
"zod": "^3.25.76"
},
"type": "module"
}

View file

@ -3,15 +3,22 @@
* Allows Calendar app to fetch contact birthdays for display
*/
import { env } from '$env/dynamic/public';
import { createApiClient } from './base-client';
import { getContactsApiUrl } from '$lib/config/runtime';
const CONTACTS_API_BASE = env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015';
// Lazy-initialized client (runtime config is async)
let contactsClient: ReturnType<typeof createApiClient> | null = null;
const contactsClient = createApiClient({
baseUrl: CONTACTS_API_BASE,
apiPrefix: '/api/v1',
});
async function getContactsClient() {
if (!contactsClient) {
const contactsApiUrl = await getContactsApiUrl();
contactsClient = createApiClient({
baseUrl: contactsApiUrl,
apiPrefix: '/api/v1',
});
}
return contactsClient;
}
// ============================================
// Types for Birthday Integration
@ -61,7 +68,13 @@ interface BirthdaysResponse {
// API Functions
// ============================================
const fetchContactsApi = contactsClient.fetchApi;
async function fetchContactsApi<T>(
endpoint: string,
options?: Parameters<ReturnType<typeof createApiClient>['fetchApi']>[1]
) {
const client = await getContactsClient();
return client.fetchApi<T>(endpoint, options);
}
/**
* Fetch all contacts with birthdays from Contacts service

View file

@ -1,25 +1,33 @@
/**
* API Client for Calendar Backend
*
* Uses runtime configuration (12-factor pattern) instead of build-time env vars.
* Token handling: Uses authStore.getValidToken() which automatically
* refreshes expired tokens before making requests.
*/
import { env } from '$env/dynamic/public';
import { getBackendUrl } from '$lib/config/runtime';
import { createApiClient, type FetchOptions, type ApiResult } from './base-client';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
let calendarClient: ReturnType<typeof createApiClient> | null = null;
const calendarClient = createApiClient({
baseUrl: API_BASE,
apiPrefix: '/api/v1',
});
async function getClient() {
if (!calendarClient) {
const backendUrl = await getBackendUrl();
calendarClient = createApiClient({
baseUrl: backendUrl,
apiPrefix: '/api/v1',
});
}
return calendarClient;
}
export async function fetchApi<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<ApiResult<T>> {
return calendarClient.fetchApi<T>(endpoint, options);
const client = await getClient();
return client.fetchApi<T>(endpoint, options);
}
// Re-export types for backwards compatibility

View file

@ -3,15 +3,22 @@
* Allows Calendar app to fetch/manage todos from the Todo service
*/
import { env } from '$env/dynamic/public';
import { createApiClient, buildQueryString } from './base-client';
import { getTodoApiUrl } from '$lib/config/runtime';
const TODO_API_BASE = env.PUBLIC_TODO_BACKEND_URL || 'http://localhost:3018';
// Lazy-initialized client (runtime config is async)
let todoClient: ReturnType<typeof createApiClient> | null = null;
const todoClient = createApiClient({
baseUrl: TODO_API_BASE,
apiPrefix: '/api/v1',
});
async function getTodoClient() {
if (!todoClient) {
const todoApiUrl = await getTodoApiUrl();
todoClient = createApiClient({
baseUrl: todoApiUrl,
apiPrefix: '/api/v1',
});
}
return todoClient;
}
// ============================================
// Types (mirrored from @todo/shared for cross-app use)
@ -173,7 +180,13 @@ interface LabelsResponse {
// API Client (using shared base client)
// ============================================
const fetchTodoApi = todoClient.fetchApi;
async function fetchTodoApi<T>(
endpoint: string,
options?: Parameters<ReturnType<typeof createApiClient>['fetchApi']>[1]
) {
const client = await getTodoClient();
return client.fetchApi<T>(endpoint, options);
}
// ============================================
// Task API Functions

View file

@ -94,11 +94,11 @@
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<header
class="calendar-header"
class:compact={settingsStore.headerCompact}
oncontextmenu={handleContextMenu}
role="banner"
>
<h1 class="header-title">{title}</h1>
</header>

View file

@ -21,13 +21,19 @@
// View type labels
const viewLabels: Record<CalendarViewType, string> = {
day: 'Tag',
'3day': '3 Tage',
'5day': '5 Tage',
week: 'Woche',
'10day': '10 Tage',
'14day': '14 Tage',
'30day': '30 Tage',
'60day': '60 Tage',
'90day': '90 Tage',
'365day': '365 Tage',
month: 'Monat',
year: 'Jahr',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Views to show in selector

View file

@ -126,13 +126,13 @@
</h3>
<div class="mini-trend">
{#each miniTrend as day}
<div class="trend-bar-container" title="{day.label}: {day.count} Events">
<div class="trend-bar-container" title="{day.label || ''}: {day.count} Events">
<div
class="trend-bar"
style="height: {(day.count / maxTrendValue) * 100}%"
class:has-events={day.count > 0}
></div>
<span class="trend-label">{day.label.charAt(0)}</span>
<span class="trend-label">{day.label?.charAt(0) || ''}</span>
</div>
{/each}
</div>

View file

@ -421,6 +421,7 @@
<div class="edit-form">
<div class="form-row">
<div class="color-preview" style="background-color: {newTagColor}"></div>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={newTagName}
@ -431,8 +432,8 @@
/>
</div>
<div class="form-row">
<label class="form-label">Gruppe</label>
<select bind:value={newTagGroupId} class="group-select">
<label for="new-tag-group" class="form-label">Gruppe</label>
<select id="new-tag-group" bind:value={newTagGroupId} class="group-select">
<option value={null}>Keine Gruppe</option>
{#each eventTagGroupsStore.groups as group (group.id)}
<option value={group.id}>{group.name}</option>
@ -471,6 +472,7 @@
<div class="edit-form">
<div class="form-row">
<div class="color-preview" style="background-color: {editTagColor}"></div>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={editTagName}
@ -481,8 +483,8 @@
/>
</div>
<div class="form-row">
<label class="form-label">Gruppe</label>
<select bind:value={editTagGroupId} class="group-select">
<label for="edit-tag-group" class="form-label">Gruppe</label>
<select id="edit-tag-group" bind:value={editTagGroupId} class="group-select">
<option value={null}>Keine Gruppe</option>
{#each eventTagGroupsStore.groups as group (group.id)}
<option value={group.id}>{group.name}</option>
@ -524,6 +526,7 @@
<div class="edit-form">
<div class="form-row">
<div class="color-preview" style="background-color: {editGroupColor}"></div>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={editGroupName}
@ -713,6 +716,7 @@
<div class="new-group-form">
<div class="form-row">
<div class="color-preview" style="background-color: {newGroupColor}"></div>
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:value={newGroupName}

View file

@ -26,25 +26,37 @@
// View labels (short versions for pill)
const viewLabels: Record<CalendarViewType, string> = {
day: '1',
'3day': '3',
'5day': '5',
week: '7',
'10day': '10',
'14day': '14',
'30day': '30',
'60day': '60',
'90day': '90',
'365day': '365',
month: 'M',
year: 'Y',
agenda: 'A',
custom: '',
};
// View titles for tooltip
const viewTitles: Record<CalendarViewType, string> = {
day: 'Tagesansicht',
'3day': '3-Tage-Ansicht',
'5day': '5-Tage-Ansicht',
week: 'Wochenansicht',
'10day': '10-Tage-Ansicht',
'14day': '14-Tage-Ansicht',
'30day': '30-Tage-Ansicht',
'60day': '60-Tage-Ansicht',
'90day': '90-Tage-Ansicht',
'365day': '365-Tage-Ansicht',
month: 'Monatsansicht',
year: 'Jahresansicht',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Get enabled views from settings

View file

@ -183,9 +183,10 @@
{#if visible}
<!-- Backdrop to block clicks on elements behind -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<div
class="context-menu-backdrop"
role="presentation"
onpointerdown={(e) => {
e.preventDefault();
e.stopPropagation();
@ -384,6 +385,7 @@
}
.custom-input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
.custom-unit {

View file

@ -1230,12 +1230,6 @@
flex-shrink: 0;
}
.calendar-dot {
width: 14px;
height: 14px;
border-radius: 50%;
}
/* Calendar pills */
.calendar-pills-container {
padding: 0.5rem 0;
@ -1290,9 +1284,6 @@
flex-shrink: 0;
}
.calendar-pill-name {
}
.row-content {
flex: 1;
min-width: 0;

View file

@ -77,8 +77,8 @@
{#if groupTags.length > 0}
<div class="group-section">
<!-- Group Header -->
<button type="button" onclick={() => toggleGroup(group.id)} class="group-header">
<div class="flex items-center gap-2">
<div class="group-header">
<button type="button" onclick={() => toggleGroup(group.id)} class="group-toggle">
{#if isExpanded(group.id)}
<CaretDown size={16} weight="bold" class="text-muted-foreground" />
{:else}
@ -90,21 +90,18 @@
></div>
<span class="font-medium">{group.name}</span>
<span class="text-xs text-muted-foreground">({groupTags.length})</span>
</div>
</button>
{#if onEditGroup}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onEditGroup(group);
}}
onclick={() => onEditGroup(group)}
class="edit-group-btn"
aria-label="Gruppe bearbeiten"
>
<Pencil size={14} />
</button>
{/if}
</button>
</div>
<!-- Tags in this group -->
{#if isExpanded(group.id)}

View file

@ -0,0 +1,145 @@
/**
* Runtime Configuration for Calendar App
*
* 12-Factor Pattern: Configuration is loaded from /config.json at runtime,
* generated by Docker entrypoint from environment variables.
* This allows the same Docker image to run in different environments
* without rebuilding.
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
BACKEND_URL: string;
AUTH_URL: string;
TODO_API_URL: string;
CONTACTS_API_URL: string;
}
/**
* Schema validation for config.json
* Ensures all required configuration is present and valid
*/
const ConfigSchema = z.object({
BACKEND_URL: z.string().url().min(1, 'BACKEND_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
TODO_API_URL: z.string().url().min(1, 'TODO_API_URL must be a valid URL'),
CONTACTS_API_URL: z.string().url().min(1, 'CONTACTS_API_URL must be a valid URL'),
});
/**
* Development defaults - only used when:
* 1. dev === true (from $app/environment)
* 2. /config.json fetch fails
*
* In production, missing config.json is a deployment error.
*/
const DEV_CONFIG: RuntimeConfig = {
BACKEND_URL: 'http://localhost:3016',
AUTH_URL: 'http://localhost:3001',
TODO_API_URL: 'http://localhost:3018',
CONTACTS_API_URL: 'http://localhost:3015',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load configuration from /config.json
* Fail-hard in production if config is missing or invalid
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Calendar] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Calendar] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) return cachedConfig;
// Return existing promise if already loading
if (configPromise) return configPromise;
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Calendar] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Calendar] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Calendar] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await loadConfig();
return config.AUTH_URL;
}
/**
* Get backend API URL
*/
export async function getBackendUrl(): Promise<string> {
const config = await loadConfig();
return config.BACKEND_URL;
}
/**
* Get todo service URL
*/
export async function getTodoApiUrl(): Promise<string> {
const config = await loadConfig();
return config.TODO_API_URL;
}
/**
* Get contacts service URL
*/
export async function getContactsApiUrl(): Promise<string> {
const config = await loadConfig();
return config.CONTACTS_API_URL;
}
/**
* Get full runtime config
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Initialize configuration (call early in app lifecycle)
* This triggers the config load and caches it for subsequent calls
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -1,45 +1,25 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
* Uses Mana Core Auth with runtime configuration (12-factor pattern)
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3014';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
}
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getBackendUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -47,10 +27,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -80,7 +60,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -107,7 +87,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -134,7 +114,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -164,7 +144,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -184,7 +164,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -208,7 +188,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -220,7 +200,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -20,6 +20,7 @@ const birthdayCalendar: Calendar = {
color: BIRTHDAY_CALENDAR.color,
isDefault: false,
isVisible: true, // Visibility controlled by settingsStore.showBirthdays
timezone: 'UTC',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};

View file

@ -4,7 +4,8 @@
interface SearchItem {
id: string;
[key: string]: unknown;
title?: string;
subtitle?: string;
}
// State

View file

@ -5,14 +5,17 @@
* - Global settings that apply to all apps
* - Per-app overrides for customization
* - localStorage caching for offline support
*
* Uses lazy initialization to wait for runtime config to load.
*/
import { browser } from '$app/environment';
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
import { getAuthUrl as getRuntimeAuthUrl } from '$lib/config/runtime';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
// Get auth URL with fallback for early access (before runtime config loads)
function getAuthUrlSync(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
@ -21,8 +24,128 @@ function getAuthUrl(): string {
return 'http://localhost:3001';
}
export const userSettings = createUserSettingsStore({
appId: 'calendar',
authUrl: getAuthUrl(),
getAccessToken: () => authStore.getAccessToken(),
});
// Lazy-initialized store (created after runtime config is loaded)
let _store: ReturnType<typeof createUserSettingsStore> | null = null;
function getOrCreateStore(authUrl?: string) {
if (!_store) {
_store = createUserSettingsStore({
appId: 'calendar',
authUrl: authUrl || getAuthUrlSync(),
getAccessToken: () => authStore.getAccessToken(),
});
}
return _store;
}
// Ensure store is initialized with correct URL from runtime config
async function ensureStore() {
if (!_store) {
const authUrl = await getRuntimeAuthUrl();
_store = createUserSettingsStore({
appId: 'calendar',
authUrl,
getAccessToken: () => authStore.getAccessToken(),
});
}
return _store;
}
// Export proxy that lazily initializes the store
export const userSettings = {
// Getters - use sync store (may have fallback URL initially)
get nav() {
return getOrCreateStore().nav;
},
get theme() {
return getOrCreateStore().theme;
},
get locale() {
return getOrCreateStore().locale;
},
get general() {
return getOrCreateStore().general;
},
get startPage() {
return getOrCreateStore().startPage;
},
get globalSettings() {
return getOrCreateStore().globalSettings;
},
get hasAppOverride() {
return getOrCreateStore().hasAppOverride;
},
get syncing() {
return getOrCreateStore().syncing;
},
get loaded() {
return getOrCreateStore().loaded;
},
get deviceId() {
return getOrCreateStore().deviceId;
},
get deviceSettings() {
return getOrCreateStore().deviceSettings;
},
get currentDeviceAppSettings() {
return getOrCreateStore().currentDeviceAppSettings;
},
// Methods that make API calls - ensure store has correct URL
async load() {
const store = await ensureStore();
return store.load();
},
async updateGlobal(
settings: Parameters<ReturnType<typeof createUserSettingsStore>['updateGlobal']>[0]
) {
const store = await ensureStore();
return store.updateGlobal(settings);
},
async updateAppOverride(
settings: Parameters<ReturnType<typeof createUserSettingsStore>['updateAppOverride']>[0]
) {
const store = await ensureStore();
return store.updateAppOverride(settings);
},
async removeAppOverride() {
const store = await ensureStore();
return store.removeAppOverride();
},
async setStartPage(appId: string, path: string) {
const store = await ensureStore();
return store.setStartPage(appId, path);
},
async updateGeneral(
settings: Parameters<ReturnType<typeof createUserSettingsStore>['updateGeneral']>[0]
) {
const store = await ensureStore();
return store.updateGeneral(settings);
},
getHiddenNavItemsForApp(appId: string) {
return getOrCreateStore().getHiddenNavItemsForApp(appId);
},
async toggleNavItemVisibility(appId: string, href: string) {
const store = await ensureStore();
return store.toggleNavItemVisibility(appId, href);
},
async setHiddenNavItems(appId: string, hiddenHrefs: string[]) {
const store = await ensureStore();
return store.setHiddenNavItems(appId, hiddenHrefs);
},
async updateDeviceAppSettings(settings: Record<string, unknown>) {
const store = await ensureStore();
return store.updateDeviceAppSettings(settings);
},
getDeviceAppSettings() {
return getOrCreateStore().getDeviceAppSettings();
},
async getDevices() {
const store = await ensureStore();
return store.getDevices();
},
async removeDevice(deviceId: string) {
const store = await ensureStore();
return store.removeDevice(deviceId);
},
};

View file

@ -128,13 +128,19 @@
// View labels
const viewLabels: Record<CalendarViewType, string> = {
day: 'Tag',
'3day': '3 Tage',
'5day': '5 Tage',
week: 'Woche',
'10day': '10 Tage',
'14day': '14 Tage',
'30day': '30 Tage',
'60day': '60 Tage',
'90day': '90 Tage',
'365day': '365 Tage',
month: 'Monat',
year: 'Jahr',
agenda: 'Agenda',
custom: 'Benutzerdefiniert',
};
// Duration options in minutes

View file

@ -15,6 +15,16 @@
let loading = $state(true);
onMount(async () => {
// Initialize runtime config first (12-factor pattern)
const { initializeConfig, getConfig } = await import('$lib/config/runtime');
await initializeConfig();
// Inject config into window for stores that need synchronous access
const config = await getConfig();
(
window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }
).__PUBLIC_MANA_CORE_AUTH_URL__ = config.AUTH_URL;
// Wait for i18n locale to be loaded
await waitLocale();

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,6 @@
{
"BACKEND_URL": "http://localhost:3016",
"AUTH_URL": "http://localhost:3001",
"TODO_API_URL": "http://localhost:3018",
"CONTACTS_API_URL": "http://localhost:3015"
}

View file

@ -12,6 +12,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
COPY packages/shared-storage ./packages/shared-storage
@ -23,6 +24,9 @@ COPY apps/chat/apps/backend ./apps/chat/apps/backend
RUN pnpm install --frozen-lockfile
# Build shared packages first
WORKDIR /app/packages/better-auth-types
RUN pnpm build
WORKDIR /app/packages/shared-errors
RUN pnpm build

View file

@ -20,6 +20,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by chat web
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
@ -45,6 +46,9 @@ COPY apps/chat/apps/web ./apps/chat/apps/web
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/better-auth-types
RUN pnpm build || true
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
@ -68,6 +72,10 @@ COPY --from=builder /app/apps/chat/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/chat/apps/web/build ./build
COPY --from=builder /app/apps/chat/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/chat/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 3000
@ -80,5 +88,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,31 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
BACKEND_URL=${BACKEND_URL:-"http://localhost:3002"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
echo "📝 Config values:"
echo " BACKEND_URL: $BACKEND_URL"
echo " AUTH_URL: $AUTH_URL"
# Generate config.json from environment variables
cat > /app/apps/chat/apps/web/build/client/config.json <<EOF
{
"BACKEND_URL": "${BACKEND_URL}",
"AUTH_URL": "${AUTH_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/chat/apps/web/build/client/config.json"
cat /app/apps/chat/apps/web/build/client/config.json
# Remove pre-compressed versions (SvelteKit serves these instead of the raw file)
rm -f /app/apps/chat/apps/web/build/client/config.json.br
rm -f /app/apps/chat/apps/web/build/client/config.json.gz
echo "🗑️ Removed stale pre-compressed config files"
echo "🚀 Starting Chat web app..."
exec "$@"

View file

@ -45,6 +45,7 @@
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"marked": "^17.0.0",
"svelte-i18n": "^4.0.1"
"svelte-i18n": "^4.0.1",
"zod": "^3.25.76"
}
}

View file

@ -107,6 +107,7 @@
{#if editingId === conv.id}
<!-- Edit Mode -->
<div class="flex items-center gap-1 px-3 py-2 mx-2">
<!-- svelte-ignore a11y_autofocus - Intentional for edit mode UX -->
<input
type="text"
bind:value={editTitle}

View file

@ -66,11 +66,11 @@
onSubmit({
id: template?.id,
name,
description: description.trim() || null,
description: description.trim() || undefined,
systemPrompt: systemPrompt,
initialQuestion: initialQuestion.trim() || null,
initialQuestion: initialQuestion.trim() || undefined,
color: selectedColor,
modelId: selectedModelId || null,
modelId: selectedModelId || undefined,
documentMode: documentMode,
});
}
@ -169,8 +169,8 @@
<!-- Color -->
<div>
<label class="block text-sm font-medium text-foreground mb-2"> Farbe </label>
<div class="flex flex-wrap gap-2">
<span class="block text-sm font-medium text-foreground mb-2" id="color-label">Farbe</span>
<div class="flex flex-wrap gap-2" role="group" aria-labelledby="color-label">
{#each TEMPLATE_COLORS as color}
<button
type="button"

View file

@ -0,0 +1,123 @@
/**
* Runtime Configuration Loader
*
* Implements 12-factor app "Config in Environment" principle.
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
* allowing the same Docker image to work across all environments.
*
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
* - Validation: Enforces schema in production (fail hard on misconfiguration)
* - Dev fallback: Only when dev=true, never in staging/prod
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
BACKEND_URL: string;
AUTH_URL: string;
}
const ConfigSchema = z.object({
BACKEND_URL: z.string().url().min(1, 'BACKEND_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
});
// Development fallback configuration (only used when dev=true)
const DEV_CONFIG: RuntimeConfig = {
BACKEND_URL: 'http://localhost:3002',
AUTH_URL: 'http://localhost:3001',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load runtime configuration from /config.json
* Uses caching to avoid multiple fetches
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Chat] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Chat] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) {
return cachedConfig;
}
// If already loading, return the existing promise
if (configPromise) {
return configPromise;
}
// Fetch config from /config.json (generated by docker-entrypoint.sh)
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Chat] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Chat] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Chat] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get the full runtime configuration
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Get the Auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await getConfig();
return config.AUTH_URL;
}
/**
* Get the Backend API URL
*/
export async function getBackendUrl(): Promise<string> {
const config = await getConfig();
return config.BACKEND_URL;
}
/**
* Initialize runtime configuration
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -6,10 +6,12 @@
*
* Token handling: Uses authStore.getValidToken() which automatically
* refreshes expired tokens before making requests.
*
* Uses runtime configuration for 12-factor compliance.
*/
import { env } from '$env/dynamic/public';
import { authStore } from '$lib/stores/auth.svelte';
import { getBackendUrl } from '$lib/config/runtime';
import type {
Conversation,
Message,
@ -35,8 +37,6 @@ export type {
ChatCompletionResponse,
};
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
@ -56,8 +56,11 @@ async function fetchApi<T>(
return { data: null, error: new Error('No authentication token') };
}
// Get backend URL from runtime config
const backendUrl = await getBackendUrl();
try {
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
const response = await fetch(`${backendUrl}/api/v1${endpoint}`, {
method,
headers: {
'Content-Type': 'application/json',

View file

@ -1,45 +1,24 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Now using Mana Core Auth instead of Supabase Auth
* Uses Mana Core Auth with runtime configuration
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3002';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
}
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getBackendUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -47,10 +26,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -80,7 +59,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -107,7 +86,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -123,7 +102,7 @@ export const authStore = {
const userData = await authService.getUserFromToken();
user = userData;
return { success: true, error: null };
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
@ -134,7 +113,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -148,7 +127,7 @@ export const authStore = {
// Mana Core Auth requires separate login after signup
if (result.needsVerification) {
return { success: true, error: null, needsVerification: true };
return { success: true, needsVerification: true };
}
// Auto sign in after successful signup
@ -164,7 +143,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -184,7 +163,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -196,7 +175,7 @@ export const authStore = {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true, error: null };
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
@ -207,7 +186,7 @@ export const authStore = {
* Get user credit balance
*/
async getCredits() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -226,7 +205,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -238,7 +217,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -166,9 +166,24 @@
</SettingsCard>
<div class="flex flex-wrap gap-4 text-sm mt-2">
<a href="#" class="text-[hsl(var(--primary))] hover:underline">Datenschutz</a>
<a href="#" class="text-[hsl(var(--primary))] hover:underline">Nutzungsbedingungen</a>
<a href="#" class="text-[hsl(var(--primary))] hover:underline">Hilfe & Support</a>
<button
onclick={() => alert('Datenschutz-Seite wird bald verfügbar sein.')}
class="text-[hsl(var(--primary))] hover:underline"
>
Datenschutz
</button>
<button
onclick={() => alert('Nutzungsbedingungen werden bald verfügbar sein.')}
class="text-[hsl(var(--primary))] hover:underline"
>
Nutzungsbedingungen
</button>
<button
onclick={() => alert('Hilfe & Support wird bald verfügbar sein.')}
class="text-[hsl(var(--primary))] hover:underline"
>
Hilfe & Support
</button>
</div>
</SettingsSection>
</SettingsPage>

View file

@ -81,11 +81,11 @@
await templatesStore.createTemplate({
userId: authStore.user.id,
name: data.name!,
description: data.description ?? null,
description: data.description,
systemPrompt: data.systemPrompt!,
initialQuestion: data.initialQuestion ?? null,
initialQuestion: data.initialQuestion,
color: data.color!,
modelId: data.modelId ?? null,
modelId: data.modelId,
isDefault: false,
documentMode: data.documentMode ?? false,
});

View file

@ -2,13 +2,17 @@
import '../app.css';
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { initializeConfig } from '$lib/config/runtime';
import Toast from '$lib/components/Toast.svelte';
let { children } = $props();
onMount(() => {
const cleanup = theme.initialize();
return cleanup;
// Initialize runtime config first (12-factor pattern)
initializeConfig();
// Initialize theme
return theme.initialize();
});
</script>

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,4 @@
{
"BACKEND_URL": "http://localhost:3002",
"AUTH_URL": "http://localhost:3001"
}

View file

@ -71,10 +71,10 @@ export interface Template {
id: string;
userId: string;
name: string;
description: string | null;
description?: string;
systemPrompt: string;
initialQuestion: string | null;
modelId: string | null;
initialQuestion?: string;
modelId?: string;
color: string;
isDefault: boolean;
documentMode: boolean;

View file

@ -12,6 +12,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
@ -23,6 +24,9 @@ COPY apps/clock/apps/backend ./apps/clock/apps/backend
RUN pnpm install --frozen-lockfile
# Build shared packages first
WORKDIR /app/packages/better-auth-types
RUN pnpm build
WORKDIR /app/packages/shared-errors
RUN pnpm build

View file

@ -20,6 +20,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by clock web
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
@ -45,6 +46,9 @@ COPY apps/clock/apps/web ./apps/clock/apps/web
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/better-auth-types
RUN pnpm build || true
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
@ -68,6 +72,10 @@ COPY --from=builder /app/apps/clock/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/clock/apps/web/build ./build
COPY --from=builder /app/apps/clock/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/clock/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 5187
@ -80,5 +88,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5187/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,31 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
API_BASE_URL=${API_BASE_URL:-"http://localhost:3017"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
echo "📝 Config values:"
echo " API_BASE_URL: $API_BASE_URL"
echo " AUTH_URL: $AUTH_URL"
# Generate config.json from environment variables
cat > /app/apps/clock/apps/web/build/client/config.json <<EOF
{
"API_BASE_URL": "${API_BASE_URL}",
"AUTH_URL": "${AUTH_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/clock/apps/web/build/client/config.json"
cat /app/apps/clock/apps/web/build/client/config.json
# Remove pre-compressed versions (SvelteKit serves these instead of the raw file)
rm -f /app/apps/clock/apps/web/build/client/config.json.br
rm -f /app/apps/clock/apps/web/build/client/config.json.gz
echo "🗑️ Removed stale pre-compressed config files"
echo "🚀 Starting Clock web app..."
exec "$@"

View file

@ -48,7 +48,8 @@
"d3": "^7.9.0",
"svelte-dnd-action": "^0.9.68",
"svelte-i18n": "^4.0.1",
"topojson-client": "^3.1.0"
"topojson-client": "^3.1.0",
"zod": "^3.25.76"
},
"type": "module"
}

View file

@ -1,10 +1,10 @@
/**
* API Client for Clock backend
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
const API_URL = 'http://localhost:3017/api/v1';
import { getApiBaseUrl } from '$lib/config/runtime';
export interface ApiResponse<T> {
data?: T;
@ -17,6 +17,7 @@ export async function fetchApi<T>(
): Promise<ApiResponse<T>> {
try {
const token = await authStore.getAccessToken();
const apiBaseUrl = await getApiBaseUrl();
const headers: HeadersInit = {
'Content-Type': 'application/json',
@ -27,7 +28,7 @@ export async function fetchApi<T>(
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_URL}${endpoint}`, {
const response = await fetch(`${apiBaseUrl}/api/v1${endpoint}`, {
...options,
headers,
});

View file

@ -0,0 +1,23 @@
/**
* Feedback Service Instance for Clock Web App
*/
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
import { browser } from '$app/environment';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
export const feedbackService = createFeedbackService({
apiUrl: getAuthUrl(),
appId: 'clock',
getAuthToken: async () => authStore.getAccessToken(),
});

View file

@ -20,7 +20,8 @@
let circumference = $derived(2 * Math.PI * radius);
let dashOffset = $derived(circumference - (percentage / 100) * circumference);
// Animation
// Animation - intentionally captures initial circumference for animation start
// svelte-ignore state_referenced_locally
let animatedOffset = $state(circumference);
let mounted = $state(false);

View file

@ -0,0 +1,123 @@
/**
* Runtime Configuration Loader
*
* Implements 12-factor app "Config in Environment" principle.
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
* allowing the same Docker image to work across all environments.
*
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
* - Validation: Enforces schema in production (fail hard on misconfiguration)
* - Dev fallback: Only when dev=true, never in staging/prod
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
API_BASE_URL: string;
AUTH_URL: string;
}
const ConfigSchema = z.object({
API_BASE_URL: z.string().url().min(1, 'API_BASE_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
});
// Development fallback configuration (only used when dev=true)
const DEV_CONFIG: RuntimeConfig = {
API_BASE_URL: 'http://localhost:3017',
AUTH_URL: 'http://localhost:3001',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load runtime configuration from /config.json
* Uses caching to avoid multiple fetches
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Clock] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Clock] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) {
return cachedConfig;
}
// If already loading, return the existing promise
if (configPromise) {
return configPromise;
}
// Fetch config from /config.json (generated by docker-entrypoint.sh)
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Clock] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Clock] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Clock] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get the full runtime configuration
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Get the Auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await getConfig();
return config.AUTH_URL;
}
/**
* Get the API base URL
*/
export async function getApiBaseUrl(): Promise<string> {
const config = await getConfig();
return config.API_BASE_URL;
}
/**
* Initialize runtime configuration
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -1,44 +1,24 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
* Uses Mana Core Auth with runtime configuration
*/
import { browser } from '$app/environment';
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3017';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3017';
}
import { getAuthUrl, getApiBaseUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getApiBaseUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -46,10 +26,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -79,7 +59,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -106,7 +86,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -133,7 +113,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -163,7 +143,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -183,7 +163,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -207,7 +187,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -219,7 +199,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -69,7 +69,8 @@
try {
// Search alarms
const alarms = await alarmsApi.getAll();
const alarmsResponse = await alarmsApi.getAll();
const alarms = alarmsResponse.data || [];
const matchingAlarms = alarms
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
@ -81,7 +82,8 @@
results.push(...matchingAlarms);
// Search timers
const timers = await timersApi.getAll();
const timersResponse = await timersApi.getAll();
const timers = timersResponse.data || [];
const matchingTimers = timers
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
.slice(0, 5)

View file

@ -265,25 +265,25 @@
}}
>
<!-- Time -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('alarm.time')}</label>
<label class="mb-4 block">
<span class="mb-1 block text-sm font-medium">{$_('alarm.time')}</span>
<input type="time" class="input time-input" bind:value={editTime} />
</div>
</label>
<!-- Label -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('alarm.label')}</label>
<label class="mb-4 block">
<span class="mb-1 block text-sm font-medium">{$_('alarm.label')}</span>
<input
type="text"
class="input"
placeholder="Arbeit, Sport, etc."
bind:value={editLabel}
/>
</div>
</label>
<!-- Repeat Days -->
<div class="mb-4">
<label class="mb-2 block text-sm font-medium">{$_('alarm.repeat')}</label>
<div class="mb-2 text-sm font-medium">{$_('alarm.repeat')}</div>
<div class="day-selector">
{#each dayNames as day, i}
<button
@ -298,25 +298,25 @@
</div>
<!-- Sound -->
<div class="mb-4">
<label class="mb-1 block text-sm font-medium">{$_('alarm.sound')}</label>
<label class="mb-4 block">
<span class="mb-1 block text-sm font-medium">{$_('alarm.sound')}</span>
<select class="input" bind:value={editSound}>
{#each ALARM_SOUNDS as sound}
<option value={sound.id}>{sound.nameDE}</option>
{/each}
</select>
</div>
</label>
<!-- Snooze -->
<div class="mb-6">
<label class="mb-1 block text-sm font-medium">{$_('alarm.snooze')}</label>
<label class="mb-6 block">
<span class="mb-1 block text-sm font-medium">{$_('alarm.snooze')}</span>
<select class="input" bind:value={editSnoozeMinutes}>
<option value={5}>5 Minuten</option>
<option value={10}>10 Minuten</option>
<option value={15}>15 Minuten</option>
<option value={30}>30 Minuten</option>
</select>
</div>
</label>
<!-- Actions -->
<div class="flex gap-3">

View file

@ -1,32 +1,8 @@
<script lang="ts">
import { browser } from '$app/environment';
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { feedbackService } from '$lib/api/feedback';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
const feedbackService = createFeedbackService({
appName: 'clock',
apiUrl: getAuthUrl(),
});
async function handleSubmit(data: { type: string; message: string; email?: string }) {
const token = await authStore.getAccessToken();
return feedbackService.submit({
...data,
token: token || undefined,
});
}
</script>
<FeedbackPage appName="Clock" onSubmit={handleSubmit} userEmail={authStore.user?.email} />
<FeedbackPage {feedbackService} appName="Clock" currentUserId={authStore.user?.id} />

View file

@ -1,6 +1,16 @@
<script lang="ts">
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
import { authStore } from '$lib/stores/auth.svelte';
function handleSubscribe(planId: string) {
console.log('Subscribe to plan:', planId);
// TODO: Implement subscription logic
}
function handleBuyPackage(packageId: string) {
console.log('Buy package:', packageId);
// TODO: Implement package purchase logic
}
</script>
<SubscriptionPage user={authStore.user} appName="Clock" />
<SubscriptionPage appName="Clock" onSubscribe={handleSubscribe} onBuyPackage={handleBuyPackage} />

View file

@ -1,6 +1,26 @@
<script lang="ts">
import { ProfilePage } from '@manacore/shared-profile-ui';
import type { UserProfile, ProfileActions } from '@manacore/shared-profile-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
// Map auth store user to UserProfile
let userProfile = $derived<UserProfile>({
id: authStore.user?.id || '',
email: authStore.user?.email || '',
role: authStore.user?.role,
});
// Profile actions
const actions: ProfileActions = {
onLogout: async () => {
await authStore.signOut();
goto('/login');
},
onDeleteAccount: () => {
alert('Konto löschen ist noch nicht implementiert.');
},
};
</script>
<ProfilePage user={authStore.user} appName="Clock" />
<ProfilePage user={userProfile} appName="Clock" {actions} />

View file

@ -49,7 +49,7 @@
<h2 class="mb-4 text-lg font-semibold">{$_('settings.clockFormat')}</h2>
<div>
<label class="mb-2 block text-sm font-medium">Zeitformat</label>
<div class="mb-2 text-sm font-medium">Zeitformat</div>
<div class="flex gap-2">
<button
class="btn btn-sm"

View file

@ -121,6 +121,7 @@
style="background-color: {focused.color}"
></div>
{#if editingLabelId === focused.id}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
class="bg-transparent border-b border-primary text-lg font-medium focus:outline-none"
@ -141,6 +142,7 @@
<button
class="text-muted-foreground hover:text-error transition-colors p-1"
onclick={() => stopwatchesStore.delete(focused.id)}
aria-label="Delete stopwatch"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -341,6 +343,7 @@
e.stopPropagation();
stopwatchesStore.delete(sw.id);
}}
aria-label="Delete stopwatch"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -397,6 +400,7 @@
e.stopPropagation();
stopwatchesStore.reset(sw.id);
}}
aria-label="Reset stopwatch"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -18,7 +18,7 @@
<span class="text-3xl">{def.icon}</span>
<div>
<h3 class="font-semibold">{def.label}</h3>
<p class="text-sm text-muted-foreground">{def.description}</p>
<p class="text-sm text-muted-foreground">{def.emoji}</p>
</div>
</div>
{#if theme.variant === variant}

View file

@ -245,6 +245,7 @@
e.stopPropagation();
handleDelete(timer.id, isLocal);
}}
aria-label="Delete timer"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -223,6 +223,7 @@
<button
class="absolute right-3 top-3 text-muted-foreground hover:text-error p-0.5"
onclick={() => removeCity(clock.id)}
aria-label="Remove city"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -269,7 +270,11 @@
<div class="card w-full max-w-md max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">{$_('worldClock.add')}</h2>
<button class="text-muted-foreground hover:text-foreground p-0.5" onclick={closeAddModal}>
<button
class="text-muted-foreground hover:text-foreground p-0.5"
onclick={closeAddModal}
aria-label="Close modal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"

View file

@ -1,35 +1,28 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
import { ClockLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
let error = $state('');
let success = $state(false);
let loading = $state(false);
// Get translations based on current locale
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
async function handleResetPassword(email: string) {
loading = true;
error = '';
success = false;
const result = await authStore.resetPassword(email);
if (result.success) {
success = true;
} else {
error = result.error || 'Passwort-Zurücksetzung fehlgeschlagen';
}
loading = false;
async function handleForgotPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<ForgotPasswordPage
appName="Clock"
appLogo=""
{loading}
{error}
{success}
onSubmit={handleResetPassword}
loginHref="/login"
logo={ClockLogo}
primaryColor="#f59e0b"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#fef3c7"
darkBackground="#1f1612"
{translations}
/>

View file

@ -1,38 +1,29 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { ClockLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import '$lib/i18n';
let error = $state('');
let loading = $state(false);
// Get translations based on current locale
const translations = $derived(getRegisterTranslations($locale || 'de'));
async function handleRegister(email: string, password: string) {
loading = true;
error = '';
const result = await authStore.signUp(email, password);
if (result.success) {
if (result.needsVerification) {
// Show verification message or redirect to verification page
goto('/login?registered=true');
} else {
goto('/');
}
} else {
error = result.error || 'Registrierung fehlgeschlagen';
}
loading = false;
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
</script>
<RegisterPage
appName="Clock"
appLogo=""
{loading}
{error}
onSubmit={handleRegister}
loginHref="/login"
logo={ClockLogo}
primaryColor="#f59e0b"
onSignUp={handleSignUp}
{goto}
successRedirect="/"
loginPath="/login"
lightBackground="#fef3c7"
darkBackground="#1f1612"
{translations}
/>

View file

@ -5,6 +5,7 @@
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { waitLocale } from '$lib/i18n';
import { initializeConfig } from '$lib/config/runtime';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
@ -13,6 +14,9 @@
let loading = $state(true);
onMount(async () => {
// Initialize runtime config first (12-factor pattern)
await initializeConfig();
// Wait for locale to be loaded
await waitLocale();

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,4 @@
{
"API_BASE_URL": "http://localhost:3017",
"AUTH_URL": "http://localhost:3001"
}

View file

@ -4,19 +4,19 @@ import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { contacts } from '../db/schema';
import {
createContactsStorage,
generateUserFileKey,
createUnifiedStorage,
getContentType,
validateFileSize,
validateFileExtension,
IMAGE_EXTENSIONS,
APPS,
} from '@manacore/shared-storage';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
@Injectable()
export class PhotoService {
private storage = createContactsStorage();
private storage = createUnifiedStorage();
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
@ -66,19 +66,22 @@ export class PhotoService {
}
}
// Generate unique key for the new photo
// Generate unique key for the new photo: {userId}/contacts/{contactId}.{ext}
const filename = `${contactId}.${extension}`;
const key = generateUserFileKey(userId, filename);
const key = `${userId}/${APPS.CONTACTS}/${filename}`;
// Upload to S3
const contentType = getContentType(filename);
await this.storage.upload(key, file.buffer, {
const result = await this.storage.upload(key, file.buffer, {
contentType,
public: true,
});
// Generate the URL (for MinIO, construct it manually)
const photoUrl = `http://localhost:9000/contacts-storage/${key}`;
// Get URL from storage client or construct manually
const photoUrl =
result.url ||
this.storage.getPublicUrl(key) ||
`${process.env.MANACORE_STORAGE_PUBLIC_URL || 'http://localhost:9000/manacore-storage'}/${key}`;
// Update contact with photo URL
await this.db
@ -125,8 +128,12 @@ export class PhotoService {
}
private extractKeyFromUrl(url: string): string | null {
// Extract key from URLs like http://localhost:9000/contacts-storage/users/xxx/file.jpg
const match = url.match(/contacts-storage\/(.+)$/);
return match ? match[1] : null;
// Extract key from URLs like http://localhost:9000/manacore-storage/userId/contacts/file.jpg
// Also support old format: http://localhost:9000/contacts-storage/users/xxx/file.jpg
const unifiedMatch = url.match(/manacore-storage\/(.+)$/);
if (unifiedMatch) return unifiedMatch[1];
const legacyMatch = url.match(/contacts-storage\/(.+)$/);
return legacyMatch ? legacyMatch[1] : null;
}
}

View file

@ -0,0 +1,98 @@
# Build stage
FROM node:20-alpine AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_BACKEND_URL=http://contacts-backend:3015
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
# Set as environment variables for build
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by contacts web
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
COPY packages/shared-feedback-service ./packages/shared-feedback-service
COPY packages/shared-feedback-ui ./packages/shared-feedback-ui
COPY packages/shared-help-content ./packages/shared-help-content
COPY packages/shared-help-types ./packages/shared-help-types
COPY packages/shared-help-ui ./packages/shared-help-ui
COPY packages/shared-i18n ./packages/shared-i18n
COPY packages/shared-icons ./packages/shared-icons
COPY packages/shared-profile-ui ./packages/shared-profile-ui
COPY packages/shared-splitscreen ./packages/shared-splitscreen
COPY packages/shared-subscription-ui ./packages/shared-subscription-ui
COPY packages/shared-tags ./packages/shared-tags
COPY packages/shared-tailwind ./packages/shared-tailwind
COPY packages/shared-theme ./packages/shared-theme
COPY packages/shared-theme-ui ./packages/shared-theme-ui
COPY packages/shared-ui ./packages/shared-ui
COPY packages/shared-utils ./packages/shared-utils
# Copy contacts packages
COPY apps/contacts/packages ./apps/contacts/packages
COPY apps/contacts/apps/web ./apps/contacts/apps/web
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/better-auth-types
RUN pnpm build || true
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
# Build the web app
WORKDIR /app/apps/contacts/apps/web
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Keep same directory structure as builder so pnpm symlinks resolve correctly
WORKDIR /app/apps/contacts/apps/web
# Copy the pnpm store that symlinks point to (at /app/node_modules/.pnpm)
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules (contains symlinks to the pnpm store)
COPY --from=builder /app/apps/contacts/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/contacts/apps/web/build ./build
COPY --from=builder /app/apps/contacts/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/contacts/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 3000
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,31 @@
#!/bin/sh
set -e
echo "🔧 Generating runtime configuration..."
# Environment variables with development defaults
BACKEND_URL=${BACKEND_URL:-"http://localhost:3015"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
echo "📝 Config values:"
echo " BACKEND_URL: $BACKEND_URL"
echo " AUTH_URL: $AUTH_URL"
# Generate config.json from environment variables
cat > /app/apps/contacts/apps/web/build/client/config.json <<EOF
{
"BACKEND_URL": "${BACKEND_URL}",
"AUTH_URL": "${AUTH_URL}"
}
EOF
echo "✅ Configuration generated at /app/apps/contacts/apps/web/build/client/config.json"
cat /app/apps/contacts/apps/web/build/client/config.json
# Remove pre-compressed versions (SvelteKit serves these instead of the raw file)
rm -f /app/apps/contacts/apps/web/build/client/config.json.br
rm -f /app/apps/contacts/apps/web/build/client/config.json.gz
echo "🗑️ Removed stale pre-compressed config files"
echo "🚀 Starting Contacts web app..."
exec "$@"

View file

@ -54,7 +54,8 @@
"d3-zoom": "^3.0.0",
"date-fns": "^4.1.0",
"lucide-svelte": "^0.556.0",
"svelte-i18n": "^4.0.1"
"svelte-i18n": "^4.0.1",
"zod": "^3.25.76"
},
"type": "module"
}

View file

@ -1,9 +1,10 @@
/**
* Centralized API client with authentication
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
import { getApiBase } from './config';
/**
* Make an authenticated API request
@ -16,6 +17,7 @@ export async function fetchWithAuth<T = unknown>(
options: RequestInit = {}
): Promise<T> {
const token = await authStore.getAccessToken();
const apiBase = await getApiBase();
const headers: HeadersInit = {
'Content-Type': 'application/json',
@ -26,7 +28,7 @@ export async function fetchWithAuth<T = unknown>(
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
const response = await fetch(`${apiBase}${url}`, {
...options,
headers,
});
@ -48,6 +50,7 @@ export async function fetchWithAuthFormData<T = unknown>(
options: RequestInit = {}
): Promise<T> {
const token = await authStore.getAccessToken();
const apiBase = await getApiBase();
const headers: HeadersInit = {
...(options.headers || {}),
@ -57,7 +60,7 @@ export async function fetchWithAuthFormData<T = unknown>(
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
const response = await fetch(`${apiBase}${url}`, {
...options,
headers,
});

View file

@ -1,13 +1,33 @@
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
/**
* API Configuration
* Uses environment variables with fallbacks for development
* Uses runtime configuration for 12-factor compliance
*/
export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`;
import { getBackendUrl, getAuthUrl } from '$lib/config/runtime';
/**
* Mana Core Auth URL
* Central authentication service URL
* Get API base URL with /api/v1 suffix
*/
export const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
export async function getApiBase(): Promise<string> {
const backendUrl = await getBackendUrl();
return `${backendUrl}/api/v1`;
}
/**
* Get Mana Core Auth URL
*/
export async function getManaAuthUrl(): Promise<string> {
return await getAuthUrl();
}
/**
* @deprecated Use getApiBase() instead for runtime config
* This export is kept for backward compatibility
*/
export const API_BASE = 'http://localhost:3015/api/v1';
/**
* @deprecated Use getManaAuthUrl() instead for runtime config
* This export is kept for backward compatibility
*/
export const MANA_AUTH_URL = 'http://localhost:3001';

View file

@ -23,6 +23,7 @@
let saving = $state(false);
let deleting = $state(false);
let uploadingPhoto = $state(false);
// svelte-ignore non_reactive_update - Element reference doesn't need reactivity
let photoInput: HTMLInputElement;
// Edit form state
@ -1089,15 +1090,6 @@
}
/* Loading */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
gap: 1rem;
}
.spinner-lg {
width: 3rem;
height: 3rem;
@ -1105,11 +1097,6 @@
animation: spin 1s linear infinite;
}
.loading-text {
color: hsl(var(--color-muted-foreground));
font-size: 0.9375rem;
}
/* Error */
.error-container {
display: flex;

View file

@ -19,6 +19,7 @@
// Infinite scroll
let intersectionObserver: IntersectionObserver | null = null;
// svelte-ignore non_reactive_update - Element reference doesn't need reactivity
let loadMoreTrigger: HTMLDivElement;
// Batch selection state

View file

@ -445,12 +445,6 @@
}
/* Loading & Empty */
.loading {
display: flex;
justify-content: center;
padding: 1.5rem;
}
.spinner {
width: 1.25rem;
height: 1.25rem;

View file

@ -157,9 +157,10 @@
>
<!-- Tags Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.tag')}</label>
<span class="filter-label" id="tag-filter-label">{$_('filters.tag')}</span>
<select
class="filter-select"
aria-labelledby="tag-filter-label"
value={selectedTagId || ''}
onchange={(e) => onTagChange(e.currentTarget.value || null)}
>
@ -172,9 +173,10 @@
<!-- Contact Info Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.contactInfo')}</label>
<span class="filter-label" id="contact-filter-label">{$_('filters.contactInfo')}</span>
<select
class="filter-select"
aria-labelledby="contact-filter-label"
value={contactFilter}
onchange={(e) => onContactFilterChange(e.currentTarget.value as ContactFilter)}
>
@ -188,9 +190,10 @@
<!-- Birthday Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.birthdayLabel')}</label>
<span class="filter-label" id="birthday-filter-label">{$_('filters.birthdayLabel')}</span>
<select
class="filter-select"
aria-labelledby="birthday-filter-label"
value={birthdayFilter}
onchange={(e) => onBirthdayFilterChange(e.currentTarget.value as BirthdayFilter)}
>
@ -204,9 +207,10 @@
<!-- Company Filter -->
{#if companies.length > 0}
<div class="filter-section">
<label class="filter-label">{$_('filters.company')}</label>
<span class="filter-label" id="company-filter-label">{$_('filters.company')}</span>
<select
class="filter-select"
aria-labelledby="company-filter-label"
value={selectedCompany || ''}
onchange={(e) => onCompanyChange(e.currentTarget.value || null)}
>
@ -320,9 +324,10 @@
<div class="filter-panel">
<!-- Tags Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.tag')}</label>
<span class="filter-label" id="tag-filter-label">{$_('filters.tag')}</span>
<select
class="filter-select"
aria-labelledby="tag-filter-label"
value={selectedTagId || ''}
onchange={(e) => onTagChange(e.currentTarget.value || null)}
>
@ -335,9 +340,10 @@
<!-- Contact Info Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.contactInfo')}</label>
<span class="filter-label" id="contact-filter-label">{$_('filters.contactInfo')}</span>
<select
class="filter-select"
aria-labelledby="contact-filter-label"
value={contactFilter}
onchange={(e) => onContactFilterChange(e.currentTarget.value as ContactFilter)}
>
@ -351,9 +357,10 @@
<!-- Birthday Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.birthdayLabel')}</label>
<span class="filter-label" id="birthday-filter-label">{$_('filters.birthdayLabel')}</span>
<select
class="filter-select"
aria-labelledby="birthday-filter-label"
value={birthdayFilter}
onchange={(e) => onBirthdayFilterChange(e.currentTarget.value as BirthdayFilter)}
>
@ -367,9 +374,10 @@
<!-- Company Filter -->
{#if companies.length > 0}
<div class="filter-section">
<label class="filter-label">{$_('filters.company')}</label>
<span class="filter-label" id="company-filter-label">{$_('filters.company')}</span>
<select
class="filter-select"
aria-labelledby="company-filter-label"
value={selectedCompany || ''}
onchange={(e) => onCompanyChange(e.currentTarget.value || null)}
>

View file

@ -15,6 +15,7 @@
let loading = $state(false);
let selectedIndex = $state(0);
let searchTimeout: ReturnType<typeof setTimeout>;
// svelte-ignore non_reactive_update - Element reference doesn't need reactivity
let inputElement: HTMLInputElement;
// Reset state when modal opens
@ -109,12 +110,13 @@
</script>
{#if open}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_interactive_supports_focus -->
<div
class="search-backdrop"
role="dialog"
aria-modal="true"
aria-label="Kontakt suchen"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>

View file

@ -49,10 +49,14 @@
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="bg-card rounded-xl shadow-xl w-full max-w-md p-6 space-y-6">
<!-- Header -->
@ -62,6 +66,7 @@
type="button"
onclick={onClose}
class="text-muted-foreground hover:text-foreground transition-colors"
aria-label={$_('common.close')}
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -92,8 +97,10 @@
<!-- Format Selection -->
<div class="space-y-3">
<label class="block text-sm font-medium text-foreground">{$_('export.format')}</label>
<div class="grid grid-cols-2 gap-3">
<span class="block text-sm font-medium text-foreground" id="format-label"
>{$_('export.format')}</span
>
<div class="grid grid-cols-2 gap-3" role="group" aria-labelledby="format-label">
<button
type="button"
onclick={() => (format = 'vcard')}

View file

@ -212,6 +212,7 @@
export { resetZoom, zoomIn, zoomOut };
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={containerElement}
class="network-graph-container"
@ -253,6 +254,7 @@
{@const isSelected = node.id === networkStore.selectedNodeId}
{@const isConnected = isConnectedToSelected(node.id, graphLinks)}
{@const isDimmed = networkStore.selectedNodeId && !isConnected}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<g
transform="translate({node.x ?? 0}, {node.y ?? 0})"
class="node"
@ -262,6 +264,7 @@
onmousedown={(e) => handleDragStart(e, node)}
onclick={() => handleNodeClick(node)}
ondblclick={() => handleNodeDoubleClick(node)}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleNodeClick(node)}
role="button"
tabindex="0"
aria-label={node.name}

View file

@ -37,6 +37,7 @@
previousNodeCount = currentNodeCount;
});
// svelte-ignore non_reactive_update - Component reference doesn't need reactivity
let graphComponent: NetworkGraph;
let graphContainer: HTMLDivElement;

View file

@ -0,0 +1,123 @@
/**
* Runtime Configuration Loader
*
* Implements 12-factor app "Config in Environment" principle.
* Configuration is loaded at runtime from /config.json generated by Docker entrypoint,
* allowing the same Docker image to work across all environments.
*
* Pattern: Client-only SPA (SSR disabled via +layout.ts)
* - Browser: Fetches /config.json (generated by docker-entrypoint.sh)
* - Validation: Enforces schema in production (fail hard on misconfiguration)
* - Dev fallback: Only when dev=true, never in staging/prod
*/
import { browser, dev } from '$app/environment';
import { z } from 'zod';
export interface RuntimeConfig {
BACKEND_URL: string;
AUTH_URL: string;
}
const ConfigSchema = z.object({
BACKEND_URL: z.string().url().min(1, 'BACKEND_URL must be a valid URL'),
AUTH_URL: z.string().url().min(1, 'AUTH_URL must be a valid URL'),
});
// Development fallback configuration (only used when dev=true)
const DEV_CONFIG: RuntimeConfig = {
BACKEND_URL: 'http://localhost:3015',
AUTH_URL: 'http://localhost:3001',
};
let cachedConfig: RuntimeConfig | null = null;
let configPromise: Promise<RuntimeConfig> | null = null;
/**
* Load runtime configuration from /config.json
* Uses caching to avoid multiple fetches
*/
async function loadConfig(): Promise<RuntimeConfig> {
// Guard: SSR should never happen (we disabled it in +layout.ts)
if (!browser) {
if (dev) {
console.warn('[Contacts] Config accessed during SSR in dev mode, using fallback');
return DEV_CONFIG;
}
throw new Error('[Contacts] Runtime config called on server - SSR should be disabled');
}
// Return cached config if available
if (cachedConfig) {
return cachedConfig;
}
// If already loading, return the existing promise
if (configPromise) {
return configPromise;
}
// Fetch config from /config.json (generated by docker-entrypoint.sh)
configPromise = fetch('/config.json')
.then((res) => {
if (!res.ok) {
if (dev) {
console.warn(
`[Contacts] Failed to load /config.json (HTTP ${res.status}), using dev defaults`
);
return DEV_CONFIG;
}
throw new Error(
`[Contacts] Failed to load /config.json (HTTP ${res.status}) - check Docker entrypoint script`
);
}
return res.json();
})
.then((config) => {
// Validate schema in production (fail hard on misconfiguration)
if (!dev) {
const result = ConfigSchema.safeParse(config);
if (!result.success) {
throw new Error(
`[Contacts] Invalid config.json schema: ${result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ')}`
);
}
}
cachedConfig = config as RuntimeConfig;
return cachedConfig;
});
return configPromise;
}
/**
* Get the full runtime configuration
*/
export async function getConfig(): Promise<RuntimeConfig> {
return loadConfig();
}
/**
* Get the Auth service URL
*/
export async function getAuthUrl(): Promise<string> {
const config = await getConfig();
return config.AUTH_URL;
}
/**
* Get the Backend API URL
*/
export async function getBackendUrl(): Promise<string> {
const config = await getConfig();
return config.BACKEND_URL;
}
/**
* Initialize runtime configuration
* Call this early in app lifecycle (e.g., +layout.svelte onMount)
*/
export async function initializeConfig(): Promise<void> {
await loadConfig();
}

View file

@ -1,45 +1,24 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
* Uses Mana Core Auth with runtime configuration
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime - fallback for SSR and client
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
// Falls back to localhost for local development
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
// Server-side (SSR): use Docker internal URL for container-to-container communication
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3015';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3015';
}
import { initializeWebAuth, type UserData } from '@manacore/shared-auth';
import { getAuthUrl, getBackendUrl } from '$lib/config/runtime';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
async function getAuthService() {
if (!browser) return null;
if (!_authService) {
const authUrl = await getAuthUrl();
const backendUrl = await getBackendUrl();
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
baseUrl: authUrl,
backendUrl: backendUrl, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
@ -47,10 +26,10 @@ function getAuthService() {
return _authService;
}
function getTokenManager() {
async function getTokenManager() {
if (!browser) return null;
// Ensure auth service is initialized first
getAuthService();
await getAuthService();
return _tokenManager;
}
@ -80,7 +59,7 @@ export const authStore = {
async initialize() {
if (initialized) return;
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
initialized = true;
loading = false;
@ -107,7 +86,7 @@ export const authStore = {
* Sign in with email and password
*/
async signIn(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -134,7 +113,7 @@ export const authStore = {
* Sign up with email and password
*/
async signUp(email: string, password: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
@ -164,7 +143,7 @@ export const authStore = {
* Sign out
*/
async signOut() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
user = null;
return;
@ -184,7 +163,7 @@ export const authStore = {
* Send password reset email
*/
async resetPassword(email: string) {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
@ -208,7 +187,7 @@ export const authStore = {
* @deprecated Use getValidToken() instead for automatic refresh
*/
async getAccessToken() {
const authService = getAuthService();
const authService = await getAuthService();
if (!authService) {
return null;
}
@ -220,7 +199,7 @@ export const authStore = {
* Automatically refreshes if the token is expired or about to expire
*/
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
const tokenManager = await getTokenManager();
if (!tokenManager) {
return null;
}

View file

@ -404,28 +404,6 @@
opacity: 1;
}
/* Loading */
.loading-container {
display: flex;
justify-content: center;
padding: 4rem 0;
}
.spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid hsl(var(--color-muted));
border-top-color: hsl(var(--color-primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Empty State */
.empty-state {
display: flex;

View file

@ -74,6 +74,10 @@
}
onMount(async () => {
// Initialize runtime config first (12-factor pattern)
const { initializeConfig } = await import('$lib/config/runtime');
await initializeConfig();
// Setup global error handling
setupGlobalErrorHandling();

View file

@ -0,0 +1,10 @@
/**
* Layout Configuration
*
* Disable SSR - this is a client-only SPA that:
* - Requires authentication (no SEO benefit)
* - Fetches all data client-side via authenticated APIs
* - Loads runtime config from /config.json (browser-only)
*/
export const ssr = false;

View file

@ -0,0 +1,4 @@
{
"BACKEND_URL": "http://localhost:3015",
"AUTH_URL": "http://localhost:3001"
}

View file

@ -20,6 +20,7 @@ COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages needed by manacore web
COPY packages/better-auth-types ./packages/better-auth-types
COPY packages/shared-auth ./packages/shared-auth
COPY packages/shared-auth-ui ./packages/shared-auth-ui
COPY packages/shared-branding ./packages/shared-branding
@ -46,6 +47,9 @@ COPY apps/manacore/apps/web ./apps/manacore/apps/web
RUN pnpm install --frozen-lockfile
# Build shared packages that need building
WORKDIR /app/packages/better-auth-types
RUN pnpm build || true
WORKDIR /app/packages/shared-auth
RUN pnpm build || true
@ -69,6 +73,10 @@ COPY --from=builder /app/apps/manacore/apps/web/node_modules ./node_modules
COPY --from=builder /app/apps/manacore/apps/web/build ./build
COPY --from=builder /app/apps/manacore/apps/web/package.json ./
# Copy entrypoint script for runtime config generation
COPY apps/manacore/apps/web/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 5173
@ -81,5 +89,8 @@ ENV HOST=0.0.0.0
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5173/health || exit 1
# Use entrypoint to generate runtime config
ENTRYPOINT ["docker-entrypoint.sh"]
# Run the app
CMD ["node", "build"]

View file

@ -0,0 +1,45 @@
#!/bin/sh
set -e
# Docker Entrypoint for Manacore Web
# Generates runtime config from environment variables
# Implements "build once, configure at runtime" pattern
echo "🔧 Generating runtime configuration..."
# Default values for local development
API_BASE_URL=${API_BASE_URL:-"http://localhost:5173"}
AUTH_URL=${AUTH_URL:-"http://localhost:3001"}
TODO_API_URL=${TODO_API_URL:-"http://localhost:3018"}
CALENDAR_API_URL=${CALENDAR_API_URL:-"http://localhost:3016"}
CLOCK_API_URL=${CLOCK_API_URL:-"http://localhost:3017"}
CONTACTS_API_URL=${CONTACTS_API_URL:-"http://localhost:3015"}
# Ensure the directory exists (it should from the build, but be safe)
mkdir -p build/client
# Generate config.json from template
cat > build/client/config.json <<EOF
{
"API_BASE_URL": "${API_BASE_URL}",
"AUTH_URL": "${AUTH_URL}",
"TODO_API_URL": "${TODO_API_URL}",
"CALENDAR_API_URL": "${CALENDAR_API_URL}",
"CLOCK_API_URL": "${CLOCK_API_URL}",
"CONTACTS_API_URL": "${CONTACTS_API_URL}"
}
EOF
echo "✅ Runtime configuration generated:"
cat build/client/config.json
# Remove pre-compressed versions (SvelteKit serves these instead of the raw file)
rm -f build/client/config.json.br
rm -f build/client/config.json.gz
echo "🗑️ Removed stale pre-compressed config files"
echo ""
echo "🚀 Starting Node server..."
# Execute the CMD (node build)
exec "$@"

View file

@ -4,10 +4,16 @@
* Authentication is handled entirely by Mana Core Auth (@manacore/shared-auth).
* No Supabase is needed - all data comes from mana-core-auth APIs.
*/
import type { UserData } from '@manacore/shared-auth';
declare global {
namespace App {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface Locals {}
interface Locals {
session?: {
access_token: string;
user: UserData;
} | null;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface PageData {}
// interface Error {}

View file

@ -1,11 +1,12 @@
/**
* Credits Service for ManaCore Web App
* Handles credit balance, transactions, and packages
*
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
import { getAuthUrl } from '$lib/config/runtime';
// Types
export interface CreditBalance {
@ -52,8 +53,9 @@ export interface CreditPurchase {
// Helper function for authenticated requests
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await authStore.getAccessToken();
const authUrl = await getAuthUrl();
const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, {
const response = await fetch(`${authUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
@ -99,7 +101,8 @@ export const creditsService = {
* Get available credit packages (public endpoint)
*/
async getPackages(): Promise<CreditPackage[]> {
const response = await fetch(`${MANA_AUTH_URL}/api/v1/credits/packages`);
const authUrl = await getAuthUrl();
const response = await fetch(`${authUrl}/api/v1/credits/packages`);
if (!response.ok) {
throw new Error('Failed to fetch packages');
}

View file

@ -1,14 +1,27 @@
/**
* Feedback Service Instance for ManaCore Web App
*
* Uses runtime configuration for 12-factor compliance
*/
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
import { getAuthUrl } from '$lib/config/runtime';
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
// Lazy initialization to allow runtime config to load first
let _feedbackService: ReturnType<typeof createFeedbackService> | null = null;
export const feedbackService = createFeedbackService({
apiUrl: MANA_AUTH_URL,
appId: 'manacore',
getAuthToken: async () => authStore.getAccessToken(),
});
async function getFeedbackService() {
if (!_feedbackService) {
const authUrl = await getAuthUrl();
_feedbackService = createFeedbackService({
apiUrl: authUrl,
appId: 'manacore',
getAuthToken: async () => authStore.getAccessToken(),
});
}
return _feedbackService;
}
// Export the async getter for components
export { getFeedbackService as getService };

View file

@ -1,11 +1,12 @@
/**
* Referrals Service for ManaCore Web App
* Handles referral codes, stats, and referral tracking
*
* Uses runtime configuration for 12-factor compliance
*/
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001'; // TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env
import { getAuthUrl } from '$lib/config/runtime';
// Types
export interface ReferralStats {
@ -54,8 +55,9 @@ export interface ReferralValidation {
// Helper function for authenticated requests
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await authStore.getAccessToken();
const authUrl = await getAuthUrl();
const response = await fetch(`${MANA_AUTH_URL}${endpoint}`, {
const response = await fetch(`${authUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
@ -109,7 +111,8 @@ export const referralsService = {
*/
async validateCode(code: string): Promise<ReferralValidation> {
try {
const response = await fetch(`${MANA_AUTH_URL}/api/v1/referrals/validate/${code}`);
const authUrl = await getAuthUrl();
const response = await fetch(`${authUrl}/api/v1/referrals/validate/${code}`);
if (!response.ok) {
return { valid: false, error: 'Invalid code' };
}

View file

@ -1,34 +1,23 @@
<script lang="ts">
/**
* Icon Component - Re-exports from @manacore/shared-icons
* This wrapper ensures backward compatibility with existing imports
* Icon Component - Wrapper for phosphor-svelte icons
* NOTE: This is a legacy wrapper. Use phosphor-svelte icons directly instead.
* Example: import { House, User } from '@manacore/shared-icons';
*/
import { iconPaths } from '@manacore/shared-icons';
interface Props {
name: keyof typeof iconPaths;
name: string;
size?: number;
class?: string;
color?: string;
}
let { name, size = 24, class: className = '', color }: Props = $props();
const path = $derived(iconPaths[name]);
</script>
{#if path}
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color || 'currentColor'}
viewBox="0 0 256 256"
class={className}
aria-hidden="true"
>
{@html path}
</svg>
{:else}
<span class="text-red-500" title="Icon '{name}' not found"></span>
{/if}
<span
class="text-orange-500"
title="Icon component is deprecated. Use direct imports from @manacore/shared-icons instead."
>
{name}
</span>

View file

@ -9,7 +9,7 @@
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let loadingState = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<CalendarEvent[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
@ -18,18 +18,18 @@
const MAX_DISPLAY = 5;
async function load() {
state = 'loading';
loadingState = 'loading';
retrying = true;
const result = await calendarService.getUpcomingEvents(7);
if (result.data) {
data = result.data;
state = 'success';
loadingState = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
loadingState = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
@ -88,9 +88,9 @@
{/if}
</div>
{#if state === 'loading'}
{#if loadingState === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
{:else if loadingState === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if (data || []).length === 0}
<div class="py-6 text-center">

View file

@ -9,7 +9,7 @@
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let loadingState = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<Conversation[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
@ -18,18 +18,18 @@
const MAX_DISPLAY = 5;
async function load() {
state = 'loading';
loadingState = 'loading';
retrying = true;
const result = await chatService.getRecentConversations(MAX_DISPLAY);
if (result.data) {
data = result.data;
state = 'success';
loadingState = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
loadingState = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
@ -69,9 +69,9 @@
</h3>
</div>
{#if state === 'loading'}
{#if loadingState === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
{:else if loadingState === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data.length === 0}
<div class="py-6 text-center">

View file

@ -9,7 +9,7 @@
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let loadingState = $state<'loading' | 'success' | 'error'>('loading');
let timers = $state<Timer[]>([]);
let alarms = $state<Alarm[]>([]);
let stats = $state<ClockStats | null>(null);
@ -18,7 +18,7 @@
let retryCount = $state(0);
async function load() {
state = 'loading';
loadingState = 'loading';
retrying = true;
const [timersResult, alarmsResult, statsResult] = await Promise.all([
@ -31,11 +31,11 @@
timers = timersResult.data;
alarms = alarmsResult.data.slice(0, 3);
stats = statsResult.data;
state = 'success';
loadingState = 'success';
retryCount = 0;
} else {
error = timersResult.error || alarmsResult.error || statsResult.error;
state = 'error';
loadingState = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
@ -79,9 +79,9 @@
</h3>
</div>
{#if state === 'loading'}
{#if loadingState === 'loading'}
<WidgetSkeleton lines={3} />
{:else if state === 'error'}
{:else if loadingState === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if timers.length === 0 && alarms.length === 0}
<div class="py-6 text-center">

View file

@ -10,7 +10,7 @@
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let loadingState = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<Contact[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
@ -23,18 +23,18 @@
const contactsUrl = isDev ? APP_URLS.contacts.dev : APP_URLS.contacts.prod;
async function load() {
state = 'loading';
loadingState = 'loading';
retrying = true;
const result = await contactsService.getFavoriteContacts(MAX_DISPLAY);
if (result.data) {
data = result.data;
state = 'success';
loadingState = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
loadingState = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
@ -71,9 +71,9 @@
</h3>
</div>
{#if state === 'loading'}
{#if loadingState === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
{:else if loadingState === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data.length === 0}
<div class="py-6 text-center">

View file

@ -9,22 +9,22 @@
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let loadingState = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<CreditBalance | null>(null);
let error = $state<string | null>(null);
let retrying = $state(false);
async function load() {
state = 'loading';
loadingState = 'loading';
retrying = true;
try {
const balance = await creditsService.getBalance();
data = balance;
state = 'success';
loadingState = 'success';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load credits';
state = 'error';
loadingState = 'error';
} finally {
retrying = false;
}
@ -43,9 +43,9 @@
{$_('dashboard.widgets.credits.title')}
</h3>
{#if state === 'loading'}
{#if loadingState === 'loading'}
<WidgetSkeleton lines={3} />
{:else if state === 'error'}
{:else if loadingState === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data}
<div class="space-y-3">

View file

@ -9,7 +9,7 @@
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let loadingState = $state<'loading' | 'success' | 'error'>('loading');
let progress = $state<LearningProgress | null>(null);
let decks = $state<Deck[]>([]);
let error = $state<string | null>(null);
@ -17,7 +17,7 @@
let retryCount = $state(0);
async function load() {
state = 'loading';
loadingState = 'loading';
retrying = true;
const [progressResult, decksResult] = await Promise.all([
@ -28,11 +28,11 @@
if (progressResult.data && decksResult.data) {
progress = progressResult.data;
decks = decksResult.data;
state = 'success';
loadingState = 'success';
retryCount = 0;
} else {
error = progressResult.error || decksResult.error;
state = 'error';
loadingState = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
@ -55,10 +55,10 @@
);
// Get decks with due cards
const decksWithDue = $derived(decks.filter((d) => d.dueCount > 0).slice(0, 3));
const decksWithDue = $derived(decks.filter((d: Deck) => d.dueCount > 0).slice(0, 3));
// Total due cards
const totalDue = $derived(decks.reduce((sum, d) => sum + d.dueCount, 0));
const totalDue = $derived(decks.reduce((sum: number, d: Deck) => sum + d.dueCount, 0));
</script>
<div>
@ -69,9 +69,9 @@
</h3>
</div>
{#if state === 'loading'}
{#if loadingState === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
{:else if loadingState === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if !progress || decks.length === 0}
<div class="py-6 text-center">

View file

@ -9,7 +9,7 @@
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let loadingState = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<GeneratedImage[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
@ -18,18 +18,18 @@
const MAX_DISPLAY = 6;
async function load() {
state = 'loading';
loadingState = 'loading';
retrying = true;
const result = await pictureService.getRecentGenerations(MAX_DISPLAY);
if (result.data) {
data = result.data;
state = 'success';
loadingState = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
loadingState = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
@ -74,9 +74,9 @@
</h3>
</div>
{#if state === 'loading'}
{#if loadingState === 'loading'}
<WidgetSkeleton lines={3} />
{:else if state === 'error'}
{:else if loadingState === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data.length === 0}
<div class="py-6 text-center">

View file

@ -9,7 +9,7 @@
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let loadingState = $state<'loading' | 'success' | 'error'>('loading');
let stats = $state<ReferralStats | null>(null);
let code = $state<ReferralCode | null>(null);
let error = $state<string | null>(null);
@ -17,7 +17,7 @@
let copied = $state(false);
async function load() {
state = 'loading';
loadingState = 'loading';
retrying = true;
try {
@ -27,10 +27,10 @@
]);
stats = statsData;
code = codeData;
state = 'success';
loadingState = 'success';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load referral data';
state = 'error';
loadingState = 'error';
} finally {
retrying = false;
}
@ -81,9 +81,9 @@
{$_('dashboard.widgets.referral.title')}
</h3>
{#if state === 'loading'}
{#if loadingState === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
{:else if loadingState === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if stats && code}
<div class="space-y-4">

View file

@ -9,7 +9,7 @@
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let loadingState = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<Task[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
@ -18,18 +18,18 @@
const MAX_DISPLAY = 5;
async function load() {
state = 'loading';
loadingState = 'loading';
retrying = true;
const result = await todoService.getTodayTasks();
if (result.data) {
data = result.data;
state = 'success';
loadingState = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
loadingState = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
@ -74,9 +74,9 @@
{/if}
</div>
{#if state === 'loading'}
{#if loadingState === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
{:else if loadingState === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if (data || []).length === 0}
<div class="py-6 text-center">

View file

@ -9,7 +9,7 @@
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let loadingState = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<Task[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
@ -18,18 +18,18 @@
const MAX_DISPLAY = 5;
async function load() {
state = 'loading';
loadingState = 'loading';
retrying = true;
const result = await todoService.getUpcomingTasks(7);
if (result.data) {
data = result.data;
state = 'success';
loadingState = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
loadingState = 'error';
// Don't retry if service is unavailable (network error)
const isServiceUnavailable = error?.includes('nicht erreichbar');
@ -77,9 +77,9 @@
{/if}
</div>
{#if state === 'loading'}
{#if loadingState === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
{:else if loadingState === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data.length === 0}
<div class="py-6 text-center">

Some files were not shown because too many files have changed in this diff Show more