mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
Merge branch 'dev' into till-dev
This commit is contained in:
commit
285e142970
251 changed files with 9752 additions and 3942 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
37
apps/calendar/apps/web/docker-entrypoint.sh
Normal file
37
apps/calendar/apps/web/docker-entrypoint.sh
Normal 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 "$@"
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
145
apps/calendar/apps/web/src/lib/config/runtime.ts
Normal file
145
apps/calendar/apps/web/src/lib/config/runtime.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
|
||||
interface SearchItem {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
// State
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
10
apps/calendar/apps/web/src/routes/+layout.ts
Normal file
10
apps/calendar/apps/web/src/routes/+layout.ts
Normal 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;
|
||||
6
apps/calendar/apps/web/static/config.json
Normal file
6
apps/calendar/apps/web/static/config.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
31
apps/chat/apps/web/docker-entrypoint.sh
Normal file
31
apps/chat/apps/web/docker-entrypoint.sh
Normal 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 "$@"
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
123
apps/chat/apps/web/src/lib/config/runtime.ts
Normal file
123
apps/chat/apps/web/src/lib/config/runtime.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
10
apps/chat/apps/web/src/routes/+layout.ts
Normal file
10
apps/chat/apps/web/src/routes/+layout.ts
Normal 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;
|
||||
4
apps/chat/apps/web/static/config.json
Normal file
4
apps/chat/apps/web/static/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"BACKEND_URL": "http://localhost:3002",
|
||||
"AUTH_URL": "http://localhost:3001"
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
31
apps/clock/apps/web/docker-entrypoint.sh
Normal file
31
apps/clock/apps/web/docker-entrypoint.sh
Normal 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 "$@"
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
23
apps/clock/apps/web/src/lib/api/feedback.ts
Normal file
23
apps/clock/apps/web/src/lib/api/feedback.ts
Normal 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(),
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
123
apps/clock/apps/web/src/lib/config/runtime.ts
Normal file
123
apps/clock/apps/web/src/lib/config/runtime.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@
|
|||
e.stopPropagation();
|
||||
handleDelete(timer.id, isLocal);
|
||||
}}
|
||||
aria-label="Delete timer"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
10
apps/clock/apps/web/src/routes/+layout.ts
Normal file
10
apps/clock/apps/web/src/routes/+layout.ts
Normal 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;
|
||||
4
apps/clock/apps/web/static/config.json
Normal file
4
apps/clock/apps/web/static/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"API_BASE_URL": "http://localhost:3017",
|
||||
"AUTH_URL": "http://localhost:3001"
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
apps/contacts/apps/web/Dockerfile
Normal file
98
apps/contacts/apps/web/Dockerfile
Normal 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"]
|
||||
31
apps/contacts/apps/web/docker-entrypoint.sh
Normal file
31
apps/contacts/apps/web/docker-entrypoint.sh
Normal 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 "$@"
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -445,12 +445,6 @@
|
|||
}
|
||||
|
||||
/* Loading & Empty */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
previousNodeCount = currentNodeCount;
|
||||
});
|
||||
|
||||
// svelte-ignore non_reactive_update - Component reference doesn't need reactivity
|
||||
let graphComponent: NetworkGraph;
|
||||
let graphContainer: HTMLDivElement;
|
||||
|
||||
|
|
|
|||
123
apps/contacts/apps/web/src/lib/config/runtime.ts
Normal file
123
apps/contacts/apps/web/src/lib/config/runtime.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
10
apps/contacts/apps/web/src/routes/+layout.ts
Normal file
10
apps/contacts/apps/web/src/routes/+layout.ts
Normal 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;
|
||||
4
apps/contacts/apps/web/static/config.json
Normal file
4
apps/contacts/apps/web/static/config.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"BACKEND_URL": "http://localhost:3015",
|
||||
"AUTH_URL": "http://localhost:3001"
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
45
apps/manacore/apps/web/docker-entrypoint.sh
Executable file
45
apps/manacore/apps/web/docker-entrypoint.sh
Executable 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 "$@"
|
||||
10
apps/manacore/apps/web/src/app.d.ts
vendored
10
apps/manacore/apps/web/src/app.d.ts
vendored
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue