feat: implement tenant OAuth flow and guest achievements
This commit is contained in:
@@ -1,98 +1,103 @@
|
||||
export async function login(email: string, password: string): Promise<{ token: string }> {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const res = await fetch('/api/v1/tenant/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Login failed');
|
||||
return res.json();
|
||||
import { authorizedFetch } from './auth/tokens';
|
||||
|
||||
type JsonValue = Record<string, any>;
|
||||
|
||||
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const body = await safeJson(response);
|
||||
console.error('[API]', message, response.status, body);
|
||||
throw new Error(message);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function safeJson(response: Response): Promise<JsonValue | null> {
|
||||
try {
|
||||
return (await response.clone().json()) as JsonValue;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEvents(): Promise<any[]> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch('/api/v1/tenant/events', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load events');
|
||||
const json = await res.json();
|
||||
return json.data ?? [];
|
||||
const response = await authorizedFetch('/api/v1/tenant/events');
|
||||
const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load events');
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
export async function createEvent(payload: { name: string; slug: string; date?: string; is_active?: boolean }): Promise<number> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch('/api/v1/tenant/events', {
|
||||
const response = await authorizedFetch('/api/v1/tenant/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create event');
|
||||
const json = await res.json();
|
||||
return json.id;
|
||||
const data = await jsonOrThrow<{ id: number }>(response, 'Failed to create event');
|
||||
return data.id;
|
||||
}
|
||||
|
||||
export async function updateEvent(id: number, payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }>): Promise<void> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch(`/api/v1/tenant/events/${id}`, {
|
||||
export async function updateEvent(
|
||||
id: number,
|
||||
payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }>
|
||||
): Promise<void> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update event');
|
||||
if (!response.ok) {
|
||||
await safeJson(response);
|
||||
throw new Error('Failed to update event');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEventPhotos(id: number): Promise<any[]> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch(`/api/v1/tenant/events/${id}/photos`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error('Failed to load photos');
|
||||
const json = await res.json();
|
||||
return json.data ?? [];
|
||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/photos`);
|
||||
const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load photos');
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
export async function featurePhoto(id: number) {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
await fetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
|
||||
export async function featurePhoto(id: number): Promise<void> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
await safeJson(response);
|
||||
throw new Error('Failed to feature photo');
|
||||
}
|
||||
}
|
||||
|
||||
export async function unfeaturePhoto(id: number) {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
await fetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
|
||||
export async function unfeaturePhoto(id: number): Promise<void> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
await safeJson(response);
|
||||
throw new Error('Failed to unfeature photo');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEvent(id: number): Promise<any> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch(`/api/v1/tenant/events/${id}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error('Failed to load event');
|
||||
return res.json();
|
||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}`);
|
||||
return jsonOrThrow<any>(response, 'Failed to load event');
|
||||
}
|
||||
|
||||
export async function toggleEvent(id: number): Promise<boolean> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error('Failed to toggle');
|
||||
const json = await res.json();
|
||||
return !!json.is_active;
|
||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST' });
|
||||
const data = await jsonOrThrow<{ is_active: boolean }>(response, 'Failed to toggle event');
|
||||
return !!data.is_active;
|
||||
}
|
||||
|
||||
export async function getEventStats(id: number): Promise<{ total: number; featured: number; likes: number }> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch(`/api/v1/tenant/events/${id}/stats`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error('Failed to load stats');
|
||||
return res.json();
|
||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/stats`);
|
||||
return jsonOrThrow<{ total: number; featured: number; likes: number }>(response, 'Failed to load stats');
|
||||
}
|
||||
|
||||
export async function createInviteLink(id: number): Promise<string> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error('Failed to create invite');
|
||||
const json = await res.json();
|
||||
return json.link as string;
|
||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST' });
|
||||
const data = await jsonOrThrow<{ link: string }>(response, 'Failed to create invite');
|
||||
return data.link;
|
||||
}
|
||||
|
||||
export async function deletePhoto(id: number) {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
await fetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
|
||||
export async function deletePhoto(id: number): Promise<void> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
await safeJson(response);
|
||||
throw new Error('Failed to delete photo');
|
||||
}
|
||||
}
|
||||
|
||||
126
resources/js/admin/auth/context.tsx
Normal file
126
resources/js/admin/auth/context.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
authorizedFetch,
|
||||
clearOAuthSession,
|
||||
clearTokens,
|
||||
completeOAuthCallback,
|
||||
isAuthError,
|
||||
loadTokens,
|
||||
registerAuthFailureHandler,
|
||||
startOAuthFlow,
|
||||
} from './tokens';
|
||||
|
||||
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||
|
||||
export interface TenantProfile {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
email?: string | null;
|
||||
event_credits_balance?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: TenantProfile | null;
|
||||
login: (redirectPath?: string) => void;
|
||||
logout: (options?: { redirect?: string }) => void;
|
||||
completeLogin: (params: URLSearchParams) => Promise<string | null>;
|
||||
refreshProfile: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [status, setStatus] = React.useState<AuthStatus>('loading');
|
||||
const [user, setUser] = React.useState<TenantProfile | null>(null);
|
||||
|
||||
const handleAuthFailure = React.useCallback(() => {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = registerAuthFailureHandler(handleAuthFailure);
|
||||
return unsubscribe;
|
||||
}, [handleAuthFailure]);
|
||||
|
||||
const refreshProfile = React.useCallback(async () => {
|
||||
try {
|
||||
const response = await authorizedFetch('/api/v1/tenant/me');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load profile');
|
||||
}
|
||||
const profile = (await response.json()) as TenantProfile;
|
||||
setUser(profile);
|
||||
setStatus('authenticated');
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
handleAuthFailure();
|
||||
} else {
|
||||
console.error('[Auth] Failed to refresh profile', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [handleAuthFailure]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) {
|
||||
setStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
refreshProfile().catch(() => {
|
||||
// refreshProfile already handled failures.
|
||||
});
|
||||
}, [refreshProfile]);
|
||||
|
||||
const login = React.useCallback((redirectPath?: string) => {
|
||||
const target = redirectPath ?? window.location.pathname + window.location.search;
|
||||
startOAuthFlow(target);
|
||||
}, []);
|
||||
|
||||
const logout = React.useCallback(({ redirect }: { redirect?: string } = {}) => {
|
||||
clearTokens();
|
||||
clearOAuthSession();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
if (redirect) {
|
||||
window.location.href = redirect;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const completeLogin = React.useCallback(
|
||||
async (params: URLSearchParams) => {
|
||||
setStatus('loading');
|
||||
try {
|
||||
const redirectTarget = await completeOAuthCallback(params);
|
||||
await refreshProfile();
|
||||
return redirectTarget;
|
||||
} catch (error) {
|
||||
handleAuthFailure();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[handleAuthFailure, refreshProfile]
|
||||
);
|
||||
|
||||
const value = React.useMemo<AuthContextValue>(
|
||||
() => ({ status, user, login, logout, completeLogin, refreshProfile }),
|
||||
[status, user, login, logout, completeLogin, refreshProfile]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = React.useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
16
resources/js/admin/auth/pkce.ts
Normal file
16
resources/js/admin/auth/pkce.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { base64UrlEncode } from './utils';
|
||||
|
||||
export function generateState(): string {
|
||||
return base64UrlEncode(window.crypto.getRandomValues(new Uint8Array(32)));
|
||||
}
|
||||
|
||||
export function generateCodeVerifier(): string {
|
||||
// RFC 7636 recommends a length between 43 and 128 characters.
|
||||
return base64UrlEncode(window.crypto.getRandomValues(new Uint8Array(64)));
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await window.crypto.subtle.digest('SHA-256', data);
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
238
resources/js/admin/auth/tokens.ts
Normal file
238
resources/js/admin/auth/tokens.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { generateCodeChallenge, generateCodeVerifier, generateState } from './pkce';
|
||||
import { decodeStoredTokens } from './utils';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'tenant_oauth_tokens.v1';
|
||||
const CODE_VERIFIER_KEY = 'tenant_oauth_code_verifier';
|
||||
const STATE_KEY = 'tenant_oauth_state';
|
||||
const REDIRECT_KEY = 'tenant_oauth_redirect';
|
||||
const TOKEN_ENDPOINT = '/api/v1/oauth/token';
|
||||
const AUTHORIZE_ENDPOINT = '/api/v1/oauth/authorize';
|
||||
const SCOPES = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
|
||||
|
||||
function getClientId(): string {
|
||||
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined;
|
||||
if (!clientId) {
|
||||
throw new Error('VITE_OAUTH_CLIENT_ID is not configured');
|
||||
}
|
||||
return clientId;
|
||||
}
|
||||
|
||||
function buildRedirectUri(): string {
|
||||
return new URL('/admin/auth/callback', window.location.origin).toString();
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(public code: 'unauthenticated' | 'unauthorized' | 'invalid_state' | 'token_exchange_failed', message?: string) {
|
||||
super(message ?? code);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthError(value: unknown): value is AuthError {
|
||||
return value instanceof AuthError;
|
||||
}
|
||||
|
||||
type AuthFailureHandler = () => void;
|
||||
const authFailureHandlers = new Set<AuthFailureHandler>();
|
||||
|
||||
function notifyAuthFailure() {
|
||||
authFailureHandlers.forEach((handler) => {
|
||||
try {
|
||||
handler();
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failure handler threw', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerAuthFailureHandler(handler: AuthFailureHandler): () => void {
|
||||
authFailureHandlers.add(handler);
|
||||
return () => {
|
||||
authFailureHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
export interface StoredTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export function loadTokens(): StoredTokens | null {
|
||||
const raw = localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
const stored = decodeStoredTokens<StoredTokens>(raw);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stored.accessToken || !stored.refreshToken || !stored.expiresAt) {
|
||||
clearTokens();
|
||||
return null;
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function saveTokens(response: TokenResponse): StoredTokens {
|
||||
const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000;
|
||||
const stored: StoredTokens = {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
expiresAt,
|
||||
scope: response.scope,
|
||||
};
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored));
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function ensureAccessToken(): Promise<string> {
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'No tokens available');
|
||||
}
|
||||
|
||||
if (Date.now() < tokens.expiresAt) {
|
||||
return tokens.accessToken;
|
||||
}
|
||||
|
||||
return refreshAccessToken(tokens.refreshToken);
|
||||
}
|
||||
|
||||
async function refreshAccessToken(refreshToken: string): Promise<string> {
|
||||
if (!refreshToken) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'Missing refresh token');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: getClientId(),
|
||||
});
|
||||
|
||||
const response = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[Auth] Refresh token request failed', response.status);
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'Refresh token invalid');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TokenResponse;
|
||||
const stored = saveTokens(data);
|
||||
return stored.accessToken;
|
||||
}
|
||||
|
||||
export async function authorizedFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
||||
const token = await ensureAccessToken();
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(input, { ...init, headers });
|
||||
if (response.status === 401) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthorized', 'Access token rejected');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function startOAuthFlow(redirectPath?: string): Promise<void> {
|
||||
const verifier = generateCodeVerifier();
|
||||
const state = generateState();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
|
||||
sessionStorage.setItem(CODE_VERIFIER_KEY, verifier);
|
||||
sessionStorage.setItem(STATE_KEY, state);
|
||||
if (redirectPath) {
|
||||
sessionStorage.setItem(REDIRECT_KEY, redirectPath);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: getClientId(),
|
||||
redirect_uri: buildRedirectUri(),
|
||||
scope: SCOPES,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
window.location.href = `${AUTHORIZE_ENDPOINT}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function completeOAuthCallback(params: URLSearchParams): Promise<string | null> {
|
||||
if (params.get('error')) {
|
||||
throw new AuthError('token_exchange_failed', params.get('error_description') ?? params.get('error') ?? 'OAuth error');
|
||||
}
|
||||
|
||||
const code = params.get('code');
|
||||
const returnedState = params.get('state');
|
||||
const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY);
|
||||
const expectedState = sessionStorage.getItem(STATE_KEY);
|
||||
|
||||
if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('invalid_state', 'PKCE state mismatch');
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: getClientId(),
|
||||
redirect_uri: buildRedirectUri(),
|
||||
code_verifier: verifier,
|
||||
});
|
||||
|
||||
const response = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[Auth] Authorization code exchange failed', response.status);
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('token_exchange_failed', 'Failed to exchange authorization code');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TokenResponse;
|
||||
saveTokens(data);
|
||||
|
||||
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
|
||||
if (redirectTarget) {
|
||||
sessionStorage.removeItem(REDIRECT_KEY);
|
||||
}
|
||||
|
||||
return redirectTarget;
|
||||
}
|
||||
|
||||
export function clearOAuthSession(): void {
|
||||
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
sessionStorage.removeItem(REDIRECT_KEY);
|
||||
}
|
||||
20
resources/js/admin/auth/utils.ts
Normal file
20
resources/js/admin/auth/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function base64UrlEncode(buffer: Uint8Array): string {
|
||||
let binary = '';
|
||||
buffer.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function decodeStoredTokens<T>(value: string | null): T | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Failed to parse stored tokens', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { AuthProvider } from './auth/context';
|
||||
import { router } from './router';
|
||||
import '../../css/app.css';
|
||||
import { initializeTheme } from '@/hooks/use-appearance';
|
||||
@@ -9,7 +10,8 @@ initializeTheme();
|
||||
const rootEl = document.getElementById('root')!;
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
35
resources/js/admin/pages/AuthCallbackPage.tsx
Normal file
35
resources/js/admin/pages/AuthCallbackPage.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const { completeLogin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
completeLogin(params)
|
||||
.then((redirectTo) => {
|
||||
navigate(redirectTo ?? '/admin', { replace: true });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Auth] Callback processing failed', err);
|
||||
if (isAuthError(err) && err.code === 'token_exchange_failed') {
|
||||
setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.');
|
||||
} else if (isAuthError(err) && err.code === 'invalid_state') {
|
||||
setError('Ungueltiger Login-Vorgang. Bitte starte die Anmeldung erneut.');
|
||||
} else {
|
||||
setError('Unbekannter Fehler beim Login.');
|
||||
}
|
||||
});
|
||||
}, [completeLogin, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
|
||||
<span className="text-base font-medium text-foreground">Anmeldung wird verarbeitet ...</span>
|
||||
{error && <div className="max-w-sm rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { getEvent, getEventStats, toggleEvent, createInviteLink } from '../api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { createInviteLink, getEvent, getEventStats, toggleEvent } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
export default function EventDetailPage() {
|
||||
const [sp] = useSearchParams();
|
||||
@@ -11,35 +12,67 @@ export default function EventDetailPage() {
|
||||
const [stats, setStats] = React.useState<{ total: number; featured: number; likes: number } | null>(null);
|
||||
const [invite, setInvite] = React.useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
const e = await getEvent(id);
|
||||
setEv(e);
|
||||
setStats(await getEventStats(id));
|
||||
}
|
||||
React.useEffect(() => { load(); }, [id]);
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
const event = await getEvent(id);
|
||||
setEv(event);
|
||||
setStats(await getEventStats(id));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function onToggle() {
|
||||
const isActive = await toggleEvent(id);
|
||||
setEv((o: any) => ({ ...(o || {}), is_active: isActive }));
|
||||
try {
|
||||
const isActive = await toggleEvent(id);
|
||||
setEv((previous: any) => ({ ...(previous || {}), is_active: isActive }));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onInvite() {
|
||||
const link = await createInviteLink(id);
|
||||
setInvite(link);
|
||||
try { await navigator.clipboard.writeText(link); } catch {}
|
||||
try {
|
||||
const link = await createInviteLink(id);
|
||||
setInvite(link);
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
} catch {
|
||||
// clipboard may be unavailable
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ev) return <div className="p-4">Lade…</div>;
|
||||
const joinLink = `${location.origin}/e/${ev.slug}`;
|
||||
if (!ev) {
|
||||
return <div className="p-4">Lade ...</div>;
|
||||
}
|
||||
|
||||
const joinLink = `${window.location.origin}/e/${ev.slug}`;
|
||||
const qrUrl = `/admin/qr?data=${encodeURIComponent(joinLink)}`;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl p-4 space-y-4">
|
||||
<div className="mx-auto max-w-3xl space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Event: {renderName(ev.name)}</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={onToggle}>{ev.is_active ? 'Deaktivieren' : 'Aktivieren'}</Button>
|
||||
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>Fotos moderieren</Button>
|
||||
<Button variant="secondary" onClick={onToggle}>
|
||||
{ev.is_active ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>
|
||||
Fotos moderieren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded border p-3 text-sm">
|
||||
@@ -47,31 +80,45 @@ export default function EventDetailPage() {
|
||||
<div>Datum: {ev.date ?? '-'}</div>
|
||||
<div>Status: {ev.is_active ? 'Aktiv' : 'Inaktiv'}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center text-sm">
|
||||
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.total ?? 0}</div><div>Fotos</div></div>
|
||||
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.featured ?? 0}</div><div>Featured</div></div>
|
||||
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.likes ?? 0}</div><div>Likes gesamt</div></div>
|
||||
<div className="grid grid-cols-1 gap-3 text-center text-sm sm:grid-cols-3">
|
||||
<StatCard label="Fotos" value={stats?.total ?? 0} />
|
||||
<StatCard label="Featured" value={stats?.featured ?? 0} />
|
||||
<StatCard label="Likes gesamt" value={stats?.likes ?? 0} />
|
||||
</div>
|
||||
<div className="rounded border p-3">
|
||||
<div className="mb-2 text-sm font-medium">Join-Link</div>
|
||||
<div className="rounded border p-3 text-sm">
|
||||
<div className="mb-2 font-medium">Join-Link</div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<input className="w-full rounded border p-2 text-sm" value={joinLink} readOnly />
|
||||
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(joinLink)}>Kopieren</Button>
|
||||
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(joinLink)}>
|
||||
Kopieren
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-2 text-sm font-medium">QR</div>
|
||||
<div className="mb-2 font-medium">QR</div>
|
||||
<img src={qrUrl} alt="QR" width={200} height={200} className="rounded border" />
|
||||
<div className="mt-3">
|
||||
<Button variant="secondary" onClick={onInvite}>Einladungslink erzeugen</Button>
|
||||
{invite && <div className="mt-2 text-xs text-muted-foreground">Erzeugt und kopiert: {invite}</div>}
|
||||
<Button variant="secondary" onClick={onInvite}>
|
||||
Einladungslink erzeugen
|
||||
</Button>
|
||||
{invite && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">Erzeugt und kopiert: {invite}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded border p-3">
|
||||
<div className="text-2xl font-semibold">{value}</div>
|
||||
<div>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderName(name: any): string {
|
||||
if (typeof name === 'string') return name;
|
||||
if (name && (name.de || name.en)) return name.de || name.en;
|
||||
return JSON.stringify(name);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { createEvent, updateEvent } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
export default function EventFormPage() {
|
||||
@@ -13,10 +14,12 @@ export default function EventFormPage() {
|
||||
const [date, setDate] = React.useState('');
|
||||
const [active, setActive] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const isEdit = !!id;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (isEdit) {
|
||||
await updateEvent(Number(id), { name, slug, date, is_active: active });
|
||||
@@ -24,21 +27,31 @@ export default function EventFormPage() {
|
||||
await createEvent({ name, slug, date, is_active: active });
|
||||
}
|
||||
nav('/admin/events');
|
||||
} finally { setSaving(false); }
|
||||
} catch (e) {
|
||||
if (!isAuthError(e)) {
|
||||
setError('Speichern fehlgeschlagen');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md p-4 space-y-3">
|
||||
<div className="mx-auto max-w-md space-y-3 p-4">
|
||||
<h1 className="text-lg font-semibold">{isEdit ? 'Event bearbeiten' : 'Neues Event'}</h1>
|
||||
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
|
||||
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input placeholder="Slug" value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||
<Input placeholder="Datum" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> Aktiv</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> Aktiv
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={save} disabled={saving || !name || !slug}>{saving ? 'Speichern…' : 'Speichern'}</Button>
|
||||
<Button onClick={save} disabled={saving || !name || !slug}>
|
||||
{saving ? 'Speichern <20>' : 'Speichern'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => nav(-1)}>Abbrechen</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
export default function EventPhotosPage() {
|
||||
const [sp] = useSearchParams();
|
||||
@@ -9,33 +10,83 @@ export default function EventPhotosPage() {
|
||||
const [rows, setRows] = React.useState<any[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
async function load() {
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setRows(await getEventPhotos(id)); } finally { setLoading(false); }
|
||||
}
|
||||
React.useEffect(() => { load(); }, [id]);
|
||||
try {
|
||||
setRows(await getEventPhotos(id));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
async function onFeature(p: any) { await featurePhoto(p.id); load(); }
|
||||
async function onUnfeature(p: any) { await unfeaturePhoto(p.id); load(); }
|
||||
async function onDelete(p: any) { await deletePhoto(p.id); load(); }
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function onFeature(photo: any) {
|
||||
try {
|
||||
await featurePhoto(photo.id);
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnfeature(photo: any) {
|
||||
try {
|
||||
await unfeaturePhoto(photo.id);
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(photo: any) {
|
||||
try {
|
||||
await deletePhoto(photo.id);
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl p-4">
|
||||
<h1 className="mb-3 text-lg font-semibold">Fotos moderieren</h1>
|
||||
{loading && <div>Lade…</div>}
|
||||
{loading && <div>Lade ...</div>}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{rows.map((p) => (
|
||||
<div key={p.id} className="rounded border p-2">
|
||||
<img src={p.thumbnail_path || p.file_path} className="mb-2 aspect-square w-full rounded object-cover" />
|
||||
<img
|
||||
src={p.thumbnail_path || p.file_path}
|
||||
className="mb-2 aspect-square w-full rounded object-cover"
|
||||
alt={p.caption ?? 'Foto'}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>❤ {p.likes_count}</span>
|
||||
<span>?? {p.likes_count}</span>
|
||||
<div className="flex gap-1">
|
||||
{p.is_featured ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => onUnfeature(p)}>Unfeature</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => onUnfeature(p)}>
|
||||
Unfeature
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={() => onFeature(p)}>Feature</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => onFeature(p)}>
|
||||
Feature
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="destructive" onClick={() => onDelete(p)}>Löschen</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => onDelete(p)}>
|
||||
L<EFBFBD>schen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,4 +95,3 @@ export default function EventPhotosPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { getEvents } from '../api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { getEvents } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
export default function EventsPage() {
|
||||
const [rows, setRows] = React.useState<any[]>([]);
|
||||
@@ -11,7 +12,15 @@ export default function EventsPage() {
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try { setRows(await getEvents()); } catch (e) { setError('Laden fehlgeschlagen'); } finally { setLoading(false); }
|
||||
try {
|
||||
setRows(await getEvents());
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Laden fehlgeschlagen');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
@@ -20,24 +29,32 @@ export default function EventsPage() {
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Meine Events</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => nav('/admin/events/new')}>Neues Event</Button>
|
||||
<Link to="/admin/settings"><Button variant="secondary">Einstellungen</Button></Link>
|
||||
<Button variant="secondary" onClick={() => nav('/admin/events/new')}>
|
||||
Neues Event
|
||||
</Button>
|
||||
<Link to="/admin/settings">
|
||||
<Button variant="secondary">Einstellungen</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{loading && <div>Lade…</div>}
|
||||
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
|
||||
{loading && <div>Lade ...</div>}
|
||||
{error && (
|
||||
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div className="divide-y rounded border">
|
||||
{rows.map((e) => (
|
||||
<div key={e.id} className="flex items-center justify-between p-3">
|
||||
{rows.map((event) => (
|
||||
<div key={event.id} className="flex items-center justify-between p-3">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{renderName(e.name)}</div>
|
||||
<div className="text-muted-foreground">Slug: {e.slug} · Datum: {e.date ?? '-'}</div>
|
||||
<div className="font-medium">{renderName(event.name)}</div>
|
||||
<div className="text-muted-foreground">Slug: {event.slug} <EFBFBD> Datum: {event.date ?? '-'}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={`/admin/events/view?id=${e.id}`} className="text-sm underline">details</Link>
|
||||
<Link to={`/admin/events/edit?id=${e.id}`} className="text-sm underline">bearbeiten</Link>
|
||||
<Link to={`/admin/events/photos?id=${e.id}`} className="text-sm underline">fotos</Link>
|
||||
<a className="text-sm underline" href={`/e/${e.slug}`} target="_blank">öffnen</a>
|
||||
<div className="flex items-center gap-2 text-sm underline">
|
||||
<Link to={`/admin/events/view?id=${event.id}`}>details</Link>
|
||||
<Link to={`/admin/events/edit?id=${event.id}`}>bearbeiten</Link>
|
||||
<Link to={`/admin/events/photos?id=${event.id}`}>fotos</Link>
|
||||
<a href={`/e/${event.slug}`} target="_blank" rel="noreferrer">
|
||||
<EFBFBD>ffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,43 +1,61 @@
|
||||
import React from 'react';
|
||||
import { login } from '../api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Location, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import { useAuth } from '../auth/context';
|
||||
|
||||
interface LocationState {
|
||||
from?: Location;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const nav = useNavigate();
|
||||
const [email, setEmail] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const { status, login } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const oauthError = searchParams.get('error');
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const { token } = await login(email, password);
|
||||
localStorage.setItem('ta_token', token);
|
||||
nav('/admin', { replace: true });
|
||||
} catch (err: any) {
|
||||
setError('Login fehlgeschlagen');
|
||||
} finally { setLoading(false); }
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
navigate('/admin', { replace: true });
|
||||
}
|
||||
}, [status, navigate]);
|
||||
|
||||
const redirectTarget = React.useMemo(() => {
|
||||
const state = location.state as LocationState | null;
|
||||
if (state?.from) {
|
||||
const from = state.from;
|
||||
const search = from.search ?? '';
|
||||
const hash = from.hash ?? '';
|
||||
return `${from.pathname}${search}${hash}`;
|
||||
}
|
||||
return '/admin';
|
||||
}, [location.state]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="mx-auto flex min-h-screen max-w-sm flex-col justify-center p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Tenant Admin</h1>
|
||||
<AppearanceToggleDropdown />
|
||||
</div>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
|
||||
<Input placeholder="E-Mail" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Input placeholder="Passwort" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<Button type="submit" disabled={loading || !email || !password} className="w-full">{loading ? 'Bitte warten…' : 'Anmelden'}</Button>
|
||||
</form>
|
||||
<div className="space-y-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und danach
|
||||
wieder zur Admin-Oberfl<EFBFBD>che gebracht.
|
||||
</p>
|
||||
{oauthError && (
|
||||
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">
|
||||
Anmeldung fehlgeschlagen: {oauthError}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={status === 'loading'}
|
||||
onClick={() => login(redirectTarget)}
|
||||
>
|
||||
{status === 'loading' ? 'Bitte warten <20>' : 'Mit Tenant-Account anmelden'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,22 +2,34 @@ import React from 'react';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/context';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const nav = useNavigate();
|
||||
function logout() {
|
||||
localStorage.removeItem('ta_token');
|
||||
nav('/admin/login', { replace: true });
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
function handleLogout() {
|
||||
logout({ redirect: '/admin/login' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm p-6">
|
||||
<h1 className="mb-4 text-lg font-semibold">Einstellungen</h1>
|
||||
<div className="mb-4">
|
||||
<div className="mx-auto max-w-sm space-y-4 p-6">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Einstellungen</h1>
|
||||
{user && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Angemeldet als {user.name ?? user.email ?? 'Tenant Admin'} - Tenant #{user.tenant_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">Darstellung</div>
|
||||
<AppearanceToggleDropdown />
|
||||
</div>
|
||||
<Button variant="destructive" onClick={logout}>Abmelden</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="destructive" onClick={handleLogout}>Abmelden</Button>
|
||||
<Button variant="secondary" onClick={() => nav(-1)}>Zurück</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import React from 'react';
|
||||
import { createBrowserRouter, Outlet, Navigate } from 'react-router-dom';
|
||||
import { createBrowserRouter, Outlet, Navigate, useLocation } from 'react-router-dom';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import EventsPage from './pages/EventsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import EventFormPage from './pages/EventFormPage';
|
||||
import EventPhotosPage from './pages/EventPhotosPage';
|
||||
import EventDetailPage from './pages/EventDetailPage';
|
||||
import AuthCallbackPage from './pages/AuthCallbackPage';
|
||||
import { useAuth } from './auth/context';
|
||||
|
||||
function RequireAuth() {
|
||||
const token = localStorage.getItem('ta_token');
|
||||
if (!token) return <Navigate to="/admin/login" replace />;
|
||||
const { status } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
||||
Bitte warten <EFBFBD>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <Navigate to="/admin/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/admin/login', element: <LoginPage /> },
|
||||
{ path: '/admin/auth/callback', element: <AuthCallbackPage /> },
|
||||
{
|
||||
path: '/admin',
|
||||
element: <RequireAuth />,
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||
import React from 'react';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import { Settings, ChevronDown, User } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { User } from 'lucide-react';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { usePollStats } from '../polling/usePollStats';
|
||||
import { useOptionalEventStats } from '../context/EventStatsContext';
|
||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { SettingsSheet } from './settings-sheet';
|
||||
|
||||
export default function Header({ slug, title = '' }: { slug?: string; title?: string }) {
|
||||
const statsContext = useOptionalEventStats();
|
||||
const identity = useOptionalGuestIdentity();
|
||||
|
||||
if (!slug) {
|
||||
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
|
||||
<div className="font-semibold">{title}</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="font-semibold">{title}</div>
|
||||
{guestName && (
|
||||
<span className="text-xs text-muted-foreground">Hi {guestName}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppearanceToggleDropdown />
|
||||
<SettingsSheet />
|
||||
@@ -22,7 +29,8 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
||||
}
|
||||
|
||||
const { event, loading: eventLoading, error: eventError } = useEventData();
|
||||
const stats = usePollStats(slug);
|
||||
const stats = statsContext && statsContext.slug === slug ? statsContext : undefined;
|
||||
const guestName = identity && identity.slug === slug && identity.hydrated && identity.name ? identity.name : null;
|
||||
|
||||
if (eventLoading) {
|
||||
return (
|
||||
@@ -48,7 +56,6 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
||||
);
|
||||
}
|
||||
|
||||
// Get event icon or generate initials
|
||||
const getEventAvatar = (event: any) => {
|
||||
if (event.type?.icon) {
|
||||
return (
|
||||
@@ -57,8 +64,7 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to initials
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
const words = name.split(' ');
|
||||
if (words.length >= 2) {
|
||||
@@ -80,6 +86,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
||||
{getEventAvatar(event)}
|
||||
<div className="flex flex-col">
|
||||
<div className="font-semibold text-base">{event.name}</div>
|
||||
{guestName && (
|
||||
<span className="text-xs text-muted-foreground">Hi {guestName}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{stats && (
|
||||
<>
|
||||
@@ -87,9 +96,9 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
||||
<User className="h-3 w-3" />
|
||||
<span>{stats.onlineGuests} online</span>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-medium">{stats.tasksSolved}</span> Aufgaben gelöst
|
||||
<span className="font-medium">{stats.tasksSolved}</span> Aufgaben geloest
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -104,88 +113,4 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSheet() {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
|
||||
<Settings className="h-5 w-5" />
|
||||
<span className="sr-only">Einstellungen öffnen</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-80 sm:w-96">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Einstellungen</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 space-y-4">
|
||||
<Collapsible defaultOpen>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Cache</div>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-2">
|
||||
<ClearCacheButton />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible defaultOpen>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Rechtliches</div>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm">
|
||||
<li><Link to="/legal/impressum" className="underline">Impressum</Link></li>
|
||||
<li><Link to="/legal/datenschutz" className="underline">Datenschutz</Link></li>
|
||||
<li><Link to="/legal/agb" className="underline">AGB</Link></li>
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearCacheButton() {
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [done, setDone] = React.useState(false);
|
||||
|
||||
async function clearAll() {
|
||||
setBusy(true); setDone(false);
|
||||
try {
|
||||
// Clear CacheStorage
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||
}
|
||||
// Clear known IndexedDB dbs (best-effort)
|
||||
if ('indexedDB' in window) {
|
||||
try { await new Promise((res, rej) => { const r = indexedDB.deleteDatabase('upload-queue'); r.onsuccess=()=>res(null); r.onerror=()=>res(null); }); } catch {}
|
||||
}
|
||||
setDone(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setTimeout(() => setDone(false), 2500);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Button variant="secondary" onClick={clearAll} disabled={busy}>
|
||||
{busy ? 'Leere Cache…' : 'Cache leeren'}
|
||||
</Button>
|
||||
{done && <div className="mt-2 text-xs text-muted-foreground">Cache gelöscht.</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export {}
|
||||
|
||||
24
resources/js/guest/components/legal-markdown.tsx
Normal file
24
resources/js/guest/components/legal-markdown.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
export function LegalMarkdown({ markdown }: Props) {
|
||||
const html = React.useMemo(() => {
|
||||
let safe = markdown
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
safe = safe.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
safe = safe.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||||
safe = safe.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
safe = safe
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => `<p>${block.replace(/\n/g, '<br/>')}</p>`)
|
||||
.join('\n');
|
||||
return safe;
|
||||
}, [markdown]);
|
||||
|
||||
return <div className="prose prose-sm dark:prose-invert" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
401
resources/js/guest/components/settings-sheet.tsx
Normal file
401
resources/js/guest/components/settings-sheet.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React from "react";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Settings, ArrowLeft, FileText, RefreshCcw, ChevronRight, UserCircle } from 'lucide-react';
|
||||
import { useOptionalGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { LegalMarkdown } from './legal-markdown';
|
||||
|
||||
const legalPages = [
|
||||
{ slug: 'impressum', label: 'Impressum' },
|
||||
{ slug: 'datenschutz', label: 'Datenschutz' },
|
||||
{ slug: 'agb', label: 'AGB' },
|
||||
] as const;
|
||||
|
||||
type ViewState =
|
||||
| { mode: 'home' }
|
||||
| { mode: 'legal'; slug: (typeof legalPages)[number]['slug']; label: string };
|
||||
|
||||
type LegalDocumentState =
|
||||
| { phase: 'idle'; title: string; body: string }
|
||||
| { phase: 'loading'; title: string; body: string }
|
||||
| { phase: 'ready'; title: string; body: string }
|
||||
| { phase: 'error'; title: string; body: string };
|
||||
|
||||
type NameStatus = 'idle' | 'saved';
|
||||
|
||||
export function SettingsSheet() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [view, setView] = React.useState<ViewState>({ mode: 'home' });
|
||||
const identity = useOptionalGuestIdentity();
|
||||
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
|
||||
const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle');
|
||||
const [savingName, setSavingName] = React.useState(false);
|
||||
const isLegal = view.mode === 'legal';
|
||||
const legalDocument = useLegalDocument(isLegal ? view.slug : null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && identity?.hydrated) {
|
||||
setNameDraft(identity.name ?? '');
|
||||
setNameStatus('idle');
|
||||
}
|
||||
}, [open, identity?.hydrated, identity?.name]);
|
||||
|
||||
const handleBack = React.useCallback(() => {
|
||||
setView({ mode: 'home' });
|
||||
}, []);
|
||||
|
||||
const handleOpenLegal = React.useCallback(
|
||||
(slug: (typeof legalPages)[number]['slug'], label: string) => {
|
||||
setView({ mode: 'legal', slug, label });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleOpenChange = React.useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setView({ mode: 'home' });
|
||||
setNameStatus('idle');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const canSaveName = Boolean(
|
||||
identity?.hydrated && nameDraft.trim() && nameDraft.trim() !== (identity?.name ?? '')
|
||||
);
|
||||
|
||||
const handleSaveName = React.useCallback(() => {
|
||||
if (!identity || !canSaveName) {
|
||||
return;
|
||||
}
|
||||
setSavingName(true);
|
||||
try {
|
||||
identity.setName(nameDraft);
|
||||
setNameStatus('saved');
|
||||
window.setTimeout(() => setNameStatus('idle'), 2000);
|
||||
} finally {
|
||||
setSavingName(false);
|
||||
}
|
||||
}, [identity, nameDraft, canSaveName]);
|
||||
|
||||
const handleResetName = React.useCallback(() => {
|
||||
if (!identity) return;
|
||||
identity.clearName();
|
||||
setNameDraft('');
|
||||
setNameStatus('idle');
|
||||
}, [identity]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-md">
|
||||
<Settings className="h-5 w-5" />
|
||||
<span className="sr-only">Einstellungen oeffnen</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="sm:max-w-md">
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="border-b bg-background px-6 py-4">
|
||||
{isLegal ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="sr-only">Zurück</span>
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="truncate">
|
||||
{legalDocument.phase === 'ready' && legalDocument.title
|
||||
? legalDocument.title
|
||||
: view.label}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{legalDocument.phase === 'loading' ? 'Laedt...' : 'Rechtlicher Hinweis'}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<SheetTitle>Einstellungen</SheetTitle>
|
||||
<SheetDescription>
|
||||
Verwalte deinen Gastzugang, rechtliche Dokumente und lokale Daten.
|
||||
</SheetDescription>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{isLegal ? (
|
||||
<LegalView document={legalDocument} onClose={() => handleOpenChange(false)} />
|
||||
) : (
|
||||
<HomeView
|
||||
identity={identity}
|
||||
nameDraft={nameDraft}
|
||||
onNameChange={setNameDraft}
|
||||
onSaveName={handleSaveName}
|
||||
onResetName={handleResetName}
|
||||
canSaveName={canSaveName}
|
||||
savingName={savingName}
|
||||
nameStatus={nameStatus}
|
||||
onOpenLegal={handleOpenLegal}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<SheetFooter className="border-t bg-muted/40 px-6 py-3 text-xs text-muted-foreground">
|
||||
<div>Gastbereich - Daten werden lokal im Browser gespeichert.</div>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function LegalView({ document, onClose }: { document: LegalDocumentState; onClose: () => void }) {
|
||||
if (document.phase === 'error') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Das Dokument konnte nicht geladen werden. Bitte versuche es spaeter erneut.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Schliessen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (document.phase === 'loading' || document.phase === 'idle') {
|
||||
return <div className="text-sm text-muted-foreground">Dokument wird geladen...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{document.title || 'Rechtlicher Hinweis'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<LegalMarkdown markdown={document.body} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HomeViewProps {
|
||||
identity: ReturnType<typeof useOptionalGuestIdentity>;
|
||||
nameDraft: string;
|
||||
onNameChange: (value: string) => void;
|
||||
onSaveName: () => void;
|
||||
onResetName: () => void;
|
||||
canSaveName: boolean;
|
||||
savingName: boolean;
|
||||
nameStatus: NameStatus;
|
||||
onOpenLegal: (slug: (typeof legalPages)[number]['slug'], label: string) => void;
|
||||
}
|
||||
|
||||
function HomeView({
|
||||
identity,
|
||||
nameDraft,
|
||||
onNameChange,
|
||||
onSaveName,
|
||||
onResetName,
|
||||
canSaveName,
|
||||
savingName,
|
||||
nameStatus,
|
||||
onOpenLegal,
|
||||
}: HomeViewProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{identity && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Dein Name</CardTitle>
|
||||
<CardDescription>
|
||||
Passe an, wie wir dich im Event begruessen. Der Name wird nur lokal gespeichert.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-pink-100 text-pink-600">
|
||||
<UserCircle className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label htmlFor="guest-name" className="text-sm font-medium">
|
||||
Anzeigename
|
||||
</Label>
|
||||
<Input
|
||||
id="guest-name"
|
||||
value={nameDraft}
|
||||
placeholder="z.B. Anna"
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
autoComplete="name"
|
||||
disabled={!identity.hydrated || savingName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={onSaveName} disabled={!canSaveName || savingName}>
|
||||
{savingName ? 'Speichere...' : 'Name speichern'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onResetName} disabled={savingName}>
|
||||
zurücksetzen
|
||||
</Button>
|
||||
{nameStatus === 'saved' && (
|
||||
<span className="text-xs text-muted-foreground">Gespeichert (ok)</span>
|
||||
)}
|
||||
{!identity.hydrated && (
|
||||
<span className="text-xs text-muted-foreground">Lade gespeicherten Namen...</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-pink-500" />
|
||||
Rechtliches
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{legalPages.map((page) => (
|
||||
<Button
|
||||
key={page.slug}
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-3"
|
||||
onClick={() => onOpenLegal(page.slug, page.label)}
|
||||
>
|
||||
<span className="text-left text-sm">{page.label}</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Offline Cache</CardTitle>
|
||||
<CardDescription>
|
||||
Loesche lokale Daten, falls Inhalte veraltet erscheinen oder Uploads haengen bleiben.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<ClearCacheButton />
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<RefreshCcw className="mt-0.5 h-3.5 w-3.5" />
|
||||
<span>Dies betrifft nur diesen Browser und muss pro Geraet erneut ausgefuehrt werden.</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useLegalDocument(slug: string | null): LegalDocumentState {
|
||||
const [state, setState] = React.useState<LegalDocumentState>({
|
||||
phase: 'idle',
|
||||
title: '',
|
||||
body: '',
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) {
|
||||
setState({ phase: 'idle', title: '', body: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setState({ phase: 'loading', title: '', body: '' });
|
||||
|
||||
fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=de`, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error('failed');
|
||||
}
|
||||
const payload = await res.json();
|
||||
setState({
|
||||
phase: 'ready',
|
||||
title: payload.title ?? '',
|
||||
body: payload.body_markdown ?? '',
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to load legal page', error);
|
||||
setState({ phase: 'error', title: '', body: '' });
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [slug]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function ClearCacheButton() {
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [done, setDone] = React.useState(false);
|
||||
|
||||
async function clearAll() {
|
||||
setBusy(true);
|
||||
setDone(false);
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
}
|
||||
if ('indexedDB' in window) {
|
||||
try {
|
||||
await new Promise((resolve) => {
|
||||
const request = indexedDB.deleteDatabase('upload-queue');
|
||||
request.onsuccess = () => resolve(null);
|
||||
request.onerror = () => resolve(null);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('IndexedDB cleanup failed', error);
|
||||
}
|
||||
}
|
||||
setDone(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
window.setTimeout(() => setDone(false), 2500);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Button variant="secondary" onClick={clearAll} disabled={busy} className="w-full">
|
||||
{busy ? 'Leere Cache...' : 'Cache leeren'}
|
||||
</Button>
|
||||
{done && <div className="text-xs text-muted-foreground">Cache geloescht.</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
resources/js/guest/context/EventStatsContext.tsx
Normal file
29
resources/js/guest/context/EventStatsContext.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { usePollStats } from '../polling/usePollStats';
|
||||
|
||||
type EventStatsContextValue = ReturnType<typeof usePollStats> & {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
const EventStatsContext = React.createContext<EventStatsContextValue | undefined>(undefined);
|
||||
|
||||
export function EventStatsProvider({ slug, children }: { slug: string; children: React.ReactNode }) {
|
||||
const stats = usePollStats(slug);
|
||||
const value = React.useMemo<EventStatsContextValue>(
|
||||
() => ({ slug, ...stats }),
|
||||
[slug, stats.onlineGuests, stats.tasksSolved, stats.latestPhotoAt, stats.loading]
|
||||
);
|
||||
return <EventStatsContext.Provider value={value}>{children}</EventStatsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useEventStats() {
|
||||
const ctx = React.useContext(EventStatsContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useEventStats must be used within an EventStatsProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalEventStats() {
|
||||
return React.useContext(EventStatsContext);
|
||||
}
|
||||
109
resources/js/guest/context/GuestIdentityContext.tsx
Normal file
109
resources/js/guest/context/GuestIdentityContext.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
|
||||
type GuestIdentityContextValue = {
|
||||
slug: string;
|
||||
name: string;
|
||||
hydrated: boolean;
|
||||
setName: (nextName: string) => void;
|
||||
clearName: () => void;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
const GuestIdentityContext = React.createContext<GuestIdentityContextValue | undefined>(undefined);
|
||||
|
||||
function storageKey(slug: string) {
|
||||
return `guestName_${slug}`;
|
||||
}
|
||||
|
||||
export function readGuestName(slug: string) {
|
||||
if (!slug || typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey(slug)) ?? '';
|
||||
} catch (error) {
|
||||
console.warn('Failed to read guest name', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function GuestIdentityProvider({ slug, children }: { slug: string; children: React.ReactNode }) {
|
||||
const [name, setNameState] = React.useState('');
|
||||
const [hydrated, setHydrated] = React.useState(false);
|
||||
|
||||
const loadFromStorage = React.useCallback(() => {
|
||||
if (!slug) {
|
||||
setHydrated(true);
|
||||
setNameState('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey(slug));
|
||||
setNameState(stored ?? '');
|
||||
} catch (error) {
|
||||
console.warn('Failed to read guest name from storage', error);
|
||||
setNameState('');
|
||||
} finally {
|
||||
setHydrated(true);
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHydrated(false);
|
||||
loadFromStorage();
|
||||
}, [loadFromStorage]);
|
||||
|
||||
const persistName = React.useCallback(
|
||||
(nextName: string) => {
|
||||
const trimmed = nextName.trim();
|
||||
setNameState(trimmed);
|
||||
try {
|
||||
if (trimmed) {
|
||||
window.localStorage.setItem(storageKey(slug), trimmed);
|
||||
} else {
|
||||
window.localStorage.removeItem(storageKey(slug));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist guest name', error);
|
||||
}
|
||||
},
|
||||
[slug]
|
||||
);
|
||||
|
||||
const clearName = React.useCallback(() => {
|
||||
setNameState('');
|
||||
try {
|
||||
window.localStorage.removeItem(storageKey(slug));
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear guest name', error);
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
const value = React.useMemo<GuestIdentityContextValue>(
|
||||
() => ({
|
||||
slug,
|
||||
name,
|
||||
hydrated,
|
||||
setName: persistName,
|
||||
clearName,
|
||||
reload: loadFromStorage,
|
||||
}),
|
||||
[slug, name, hydrated, persistName, clearName, loadFromStorage]
|
||||
);
|
||||
|
||||
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;
|
||||
}
|
||||
|
||||
export function useGuestIdentity() {
|
||||
const ctx = React.useContext(GuestIdentityContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useGuestIdentity must be used within a GuestIdentityProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useOptionalGuestIdentity() {
|
||||
return React.useContext(GuestIdentityContext);
|
||||
}
|
||||
104
resources/js/guest/hooks/useGuestTaskProgress.ts
Normal file
104
resources/js/guest/hooks/useGuestTaskProgress.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
|
||||
function storageKey(slug: string) {
|
||||
return `guestTasks_${slug}`;
|
||||
}
|
||||
|
||||
function parseStored(value: string | null) {
|
||||
if (!value) {
|
||||
return [] as number[];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((item) => Number.isInteger(item)) as number[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse task progress from storage', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function useGuestTaskProgress(slug: string | undefined) {
|
||||
const [completed, setCompleted] = React.useState<number[]>([]);
|
||||
const [hydrated, setHydrated] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) {
|
||||
setCompleted([]);
|
||||
setHydrated(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey(slug));
|
||||
setCompleted(parseStored(stored));
|
||||
} catch (error) {
|
||||
console.warn('Failed to read task progress', error);
|
||||
setCompleted([]);
|
||||
} finally {
|
||||
setHydrated(true);
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
const persist = React.useCallback(
|
||||
(next: number[]) => {
|
||||
if (!slug) return;
|
||||
setCompleted(next);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(slug), JSON.stringify(next));
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist task progress', error);
|
||||
}
|
||||
},
|
||||
[slug]
|
||||
);
|
||||
|
||||
const markCompleted = React.useCallback(
|
||||
(taskId: number) => {
|
||||
if (!slug || !Number.isInteger(taskId)) {
|
||||
return;
|
||||
}
|
||||
setCompleted((prev) => {
|
||||
if (prev.includes(taskId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev, taskId];
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(slug), JSON.stringify(next));
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist task progress', error);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[slug]
|
||||
);
|
||||
|
||||
const clearProgress = React.useCallback(() => {
|
||||
if (!slug) return;
|
||||
setCompleted([]);
|
||||
try {
|
||||
window.localStorage.removeItem(storageKey(slug));
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear task progress', error);
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
const isCompleted = React.useCallback(
|
||||
(taskId: number | null | undefined) => {
|
||||
if (!Number.isInteger(taskId)) return false;
|
||||
return completed.includes(taskId as number);
|
||||
},
|
||||
[completed]
|
||||
);
|
||||
|
||||
return {
|
||||
hydrated,
|
||||
completed,
|
||||
completedCount: completed.length,
|
||||
markCompleted,
|
||||
clearProgress,
|
||||
isCompleted,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,444 @@
|
||||
import React from 'react';
|
||||
import { Page } from './_util';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
AchievementBadge,
|
||||
AchievementsPayload,
|
||||
FeedEntry,
|
||||
LeaderboardEntry,
|
||||
TimelinePoint,
|
||||
TopPhotoHighlight,
|
||||
TrendingEmotionHighlight,
|
||||
fetchAchievements,
|
||||
} from '../services/achievementApi';
|
||||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { Sparkles, Award, Trophy, Camera, Users, BarChart2, Flame } from 'lucide-react';
|
||||
|
||||
export default function AchievementsPage() {
|
||||
function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat('de-DE').format(value);
|
||||
}
|
||||
|
||||
function formatRelativeTime(input: string): string {
|
||||
const date = new Date(input);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const diff = Date.now() - date.getTime();
|
||||
const minute = 60_000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
if (diff < minute) return 'gerade eben';
|
||||
if (diff < hour) {
|
||||
const minutes = Math.round(diff / minute);
|
||||
return `vor ${minutes} Min`;
|
||||
}
|
||||
if (diff < day) {
|
||||
const hours = Math.round(diff / hour);
|
||||
return `vor ${hours} Std`;
|
||||
}
|
||||
const days = Math.round(diff / day);
|
||||
return `vor ${days} Tagen`;
|
||||
}
|
||||
|
||||
function badgeVariant(earned: boolean): string {
|
||||
return earned ? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/30' : 'bg-muted text-muted-foreground';
|
||||
}
|
||||
|
||||
function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string; icon: React.ElementType; entries: LeaderboardEntry[]; emptyCopy: string }) {
|
||||
return (
|
||||
<Page title="Erfolge">
|
||||
<p>Badges and progress placeholder.</p>
|
||||
</Page>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center gap-2 pb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||||
<CardDescription className="text-xs">Top 5 Teilnehmer dieses Events</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{emptyCopy}</p>
|
||||
) : (
|
||||
<ol className="space-y-2 text-sm">
|
||||
{entries.map((entry, index) => (
|
||||
<li key={`${entry.guest}-${index}`} className="flex items-center justify-between rounded-lg border border-border/50 bg-muted/30 px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground">#{index + 1}</span>
|
||||
<span className="font-medium text-foreground">{entry.guest || 'Gast'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{entry.photos} Fotos</span>
|
||||
<span>{entry.likes} Likes</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
|
||||
if (badges.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Badges</CardTitle>
|
||||
<CardDescription>Erfuelle Aufgaben und sammle Likes, um Badges freizuschalten.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Noch keine Badges verfuegbar.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Badges</CardTitle>
|
||||
<CardDescription>Dein Fortschritt bei den verfuegbaren Erfolgen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{badges.map((badge) => (
|
||||
<div key={badge.id} className={cn('rounded-xl border px-4 py-3', badgeVariant(badge.earned))}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{badge.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{badge.description}</p>
|
||||
</div>
|
||||
<Award className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{badge.earned ? 'Abgeschlossen' : `Fortschritt: ${badge.progress}/${badge.target}`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Timeline({ points }: { points: TimelinePoint[] }) {
|
||||
if (points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeline</CardTitle>
|
||||
<CardDescription>Wie das Event im Laufe der Zeit Fahrt aufgenommen hat.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{points.map((point) => (
|
||||
<div key={point.date} className="flex items-center justify-between rounded-lg border border-border/40 bg-muted/20 px-3 py-2">
|
||||
<span className="font-medium text-foreground">{point.date}</span>
|
||||
<span className="text-muted-foreground">{point.photos} Fotos | {point.guests} Gaeste</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Feed({ feed }: { feed: FeedEntry[] }) {
|
||||
if (feed.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Live Feed</CardTitle>
|
||||
<CardDescription>Neue Uploads erscheinen hier in Echtzeit.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Noch keine Uploads - starte die Kamera und lege los!</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Live Feed</CardTitle>
|
||||
<CardDescription>Die neuesten Momente aus deinem Event.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{feed.map((item) => (
|
||||
<div key={item.photoId} className="flex items-center gap-3 rounded-lg border border-border/40 bg-muted/20 p-3">
|
||||
{item.thumbnail ? (
|
||||
<img src={item.thumbnail} alt="Vorschau" className="h-16 w-16 rounded-md object-cover" />
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-md bg-muted"> <Camera className="h-6 w-6 text-muted-foreground" /> </div>
|
||||
)}
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-semibold text-foreground">{item.guest || 'Gast'}</p>
|
||||
{item.task && <p className="text-xs text-muted-foreground">Aufgabe: {item.task}</p>}
|
||||
<div className="mt-1 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{formatRelativeTime(item.createdAt)}</span>
|
||||
<span>{item.likes} Likes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Highlights({ topPhoto, trendingEmotion }: { topPhoto: TopPhotoHighlight | null; trendingEmotion: TrendingEmotionHighlight | null }) {
|
||||
if (!topPhoto && !trendingEmotion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{topPhoto && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Publikumsliebling</CardTitle>
|
||||
<CardDescription>Das Foto mit den meisten Likes.</CardDescription>
|
||||
</div>
|
||||
<Trophy className="h-6 w-6 text-amber-400" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="overflow-hidden rounded-xl border border-border/40">
|
||||
{topPhoto.thumbnail ? (
|
||||
<img src={topPhoto.thumbnail} alt="Top Foto" className="h-48 w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-48 w-full items-center justify-center bg-muted text-muted-foreground">Kein Vorschau-Bild</div>
|
||||
)}
|
||||
</div>
|
||||
<p><span className="font-semibold text-foreground">{topPhoto.guest || 'Gast'}</span> <EFBFBD> {topPhoto.likes} Likes</p>
|
||||
{topPhoto.task && <p className="text-muted-foreground">Aufgabe: {topPhoto.task}</p>}
|
||||
<p className="text-xs text-muted-foreground">{formatRelativeTime(topPhoto.createdAt)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{trendingEmotion && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Trend-Emotion</CardTitle>
|
||||
<CardDescription>Diese Stimmung taucht gerade besonders oft auf.</CardDescription>
|
||||
</div>
|
||||
<Flame className="h-6 w-6 text-pink-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-lg font-semibold text-foreground">{trendingEmotion.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{trendingEmotion.count} Fotos mit dieser Stimmung</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCards({ data }: { data: AchievementsPayload }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 py-4">
|
||||
<span className="text-xs uppercase text-muted-foreground">Fotos gesamt</span>
|
||||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.totalPhotos)}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 py-4">
|
||||
<span className="text-xs uppercase text-muted-foreground">Aktive Gaeste</span>
|
||||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.uniqueGuests)}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 py-4">
|
||||
<span className="text-xs uppercase text-muted-foreground">Erfuellte Aufgaben</span>
|
||||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.tasksSolved)}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 py-4">
|
||||
<span className="text-xs uppercase text-muted-foreground">Likes insgesamt</span>
|
||||
<span className="text-2xl font-semibold text-foreground">{formatNumber(data.summary.likesTotal)}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalActions({ slug }: { slug: string }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild>
|
||||
<Link to={`/e/${encodeURIComponent(slug)}/upload`} className="flex items-center gap-2">
|
||||
<Camera className="h-4 w-4" />
|
||||
Neues Foto hochladen
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/e/${encodeURIComponent(slug)}/tasks`} className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Aufgabe ziehen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AchievementsPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const identity = useGuestIdentity();
|
||||
const [data, setData] = useState<AchievementsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'personal' | 'event' | 'feed'>('personal');
|
||||
|
||||
const personalName = identity.hydrated && identity.name ? identity.name : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchAchievements(slug, personalName, controller.signal)
|
||||
.then((payload) => {
|
||||
setData(payload);
|
||||
if (!payload.personal) {
|
||||
setActiveTab('event');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('Failed to load achievements', err);
|
||||
setError(err.message || 'Erfolge konnten nicht geladen werden.');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
return () => controller.abort();
|
||||
}, [slug, personalName]);
|
||||
|
||||
const hasPersonal = Boolean(data?.personal);
|
||||
|
||||
if (!slug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-24">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
|
||||
<Award className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Erfolge</h1>
|
||||
<p className="text-sm text-muted-foreground">Behalte deine Highlights, Badges und die aktivsten Gaeste im Blick.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="flex items-center justify-between gap-3">
|
||||
<span>{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setActiveTab(hasPersonal ? 'personal' : 'event')}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
<SummaryCards data={data} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant={activeTab === 'personal' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('personal')}
|
||||
disabled={!hasPersonal}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Meine Erfolge
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'event' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('event')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
Event Highlights
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'feed' ? 'default' : 'outline'}
|
||||
onClick={() => setActiveTab('feed')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<BarChart2 className="h-4 w-4" />
|
||||
Live Feed
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{activeTab === 'personal' && hasPersonal && data.personal && (
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-semibold">Hi {data.personal.guestName || identity.name || 'Gast'}!</CardTitle>
|
||||
<CardDescription>
|
||||
{data.personal.photos} Fotos | {data.personal.tasks} Aufgaben | {data.personal.likes} Likes
|
||||
</CardDescription>
|
||||
</div>
|
||||
<PersonalActions slug={slug} />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<BadgesGrid badges={data.personal.badges} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'event' && (
|
||||
<div className="space-y-5">
|
||||
<Highlights topPhoto={data.highlights.topPhoto} trendingEmotion={data.highlights.trendingEmotion} />
|
||||
<Timeline points={data.highlights.timeline} />
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Leaderboard
|
||||
title="Top Uploads"
|
||||
icon={Users}
|
||||
entries={data.leaderboards.uploads}
|
||||
emptyCopy="Noch keine Uploads - sobald Fotos vorhanden sind, erscheinen sie hier."
|
||||
/>
|
||||
<Leaderboard
|
||||
title="Beliebteste Gaeste"
|
||||
icon={Trophy}
|
||||
entries={data.leaderboards.likes}
|
||||
emptyCopy="Likes fehlen noch - motiviere die Gaeste, Fotos zu liken."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'feed' && <Feed feed={data.feed} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,17 +106,7 @@ export default function GalleryPage() {
|
||||
imageUrl = imageUrl.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
// Extended debug logging
|
||||
console.log(`Photo ${p.id} URL processing:`, {
|
||||
id: p.id,
|
||||
original: imgSrc,
|
||||
thumbnail_path: p.thumbnail_path,
|
||||
file_path: p.file_path,
|
||||
cleanPath,
|
||||
finalUrl: imageUrl,
|
||||
isHttp: imageUrl?.startsWith('http'),
|
||||
startsWithStorage: imageUrl?.startsWith('/storage/')
|
||||
});
|
||||
// Production: avoid heavy console logging for each image
|
||||
|
||||
return (
|
||||
<Card key={p.id} className="relative overflow-hidden">
|
||||
@@ -133,11 +123,8 @@ export default function GalleryPage() {
|
||||
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
|
||||
className="aspect-square w-full object-cover bg-gray-200"
|
||||
onError={(e) => {
|
||||
console.error(`❌ Failed to load image ${p.id}:`, imageUrl);
|
||||
console.error('Error details:', e);
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
|
||||
}}
|
||||
onLoad={() => console.log(`✅ Successfully loaded image ${p.id}:`, imageUrl)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,186 @@
|
||||
import React from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { usePollStats } from '../polling/usePollStats';
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Header from '../components/Header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import EmotionPicker from '../components/EmotionPicker';
|
||||
import GalleryPreview from '../components/GalleryPreview';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { useEventStats } from '../context/EventStatsContext';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react';
|
||||
|
||||
export default function HomePage() {
|
||||
const { slug } = useParams();
|
||||
const stats = usePollStats(slug!);
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { name, hydrated } = useGuestIdentity();
|
||||
const stats = useEventStats();
|
||||
const { event } = useEventData();
|
||||
const { completedCount } = useGuestTaskProgress(slug);
|
||||
|
||||
if (!slug) return null;
|
||||
|
||||
const displayName = hydrated && name ? name : 'Gast';
|
||||
const latestUploadText = formatLatestUpload(stats.latestPhotoAt);
|
||||
|
||||
const primaryActions: Array<{ to: string; label: string; description: string; icon: React.ReactNode }> = [
|
||||
{
|
||||
to: 'tasks',
|
||||
label: 'Aufgabe ziehen',
|
||||
description: 'Hol dir deine naechste Challenge',
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
to: 'upload',
|
||||
label: 'Direkt hochladen',
|
||||
description: 'Teile deine neuesten Fotos',
|
||||
icon: <UploadCloud className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
to: 'gallery',
|
||||
label: 'Galerie ansehen',
|
||||
description: 'Lass dich von anderen inspirieren',
|
||||
icon: <Images className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
const checklistItems = [
|
||||
'Aufgabe auswaehlen oder starten',
|
||||
'Emotion festhalten und Foto schiessen',
|
||||
'Bild hochladen und Credits sammeln',
|
||||
];
|
||||
|
||||
return (
|
||||
<Page title={`Event: ${slug}`}>
|
||||
<Header slug={slug!} title={`Event: ${slug}`} />
|
||||
<div className="px-4 py-6 pb-20 space-y-6"> {/* Consistent spacing */}
|
||||
{/* Prominent Draw Task Button */}
|
||||
<Link to="tasks">
|
||||
<Button className="w-full bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white py-4 rounded-xl text-base font-semibold mb-6 shadow-lg hover:shadow-xl transition-all duration-200">
|
||||
<span className="flex items-center gap-2">
|
||||
🎲 Aufgabe ziehen
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="space-y-6 pb-24">
|
||||
<HeroCard name={displayName} eventName={event?.name ?? 'Dein Event'} tasksCompleted={completedCount} />
|
||||
|
||||
{/* How do you feel? Section */}
|
||||
<EmotionPicker />
|
||||
<Card>
|
||||
<CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4">
|
||||
<StatTile
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
label="Gleichzeitig online"
|
||||
value={`${stats.onlineGuests}`}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
label="Aufgaben gelöst"
|
||||
value={`${stats.tasksSolved}`}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<TimerReset className="h-4 w-4" />}
|
||||
label="Letzter Upload"
|
||||
value={latestUploadText}
|
||||
/>
|
||||
<StatTile
|
||||
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||
label="Deine erledigten Aufgaben"
|
||||
value={`${completedCount}`}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<GalleryPreview slug={slug!} />
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<BottomNav />
|
||||
</Page>
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Deine Aktionen</h2>
|
||||
<span className="text-xs text-muted-foreground">Waehle aus, womit du starten willst</span>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{primaryActions.map((action) => (
|
||||
<Link to={action.to} key={action.to} className="block">
|
||||
<Card className="transition-all hover:shadow-lg">
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-pink-100 text-pink-600">
|
||||
{action.icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base font-semibold">{action.label}</span>
|
||||
<span className="text-sm text-muted-foreground">{action.description}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" asChild className="w-full">
|
||||
<Link to="queue">Uploads in Warteschlange ansehen</Link>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dein Fortschritt</CardTitle>
|
||||
<CardDescription>Halte dich an diese drei kurzen Schritte fuer die besten Ergebnisse.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{checklistItems.map((item) => (
|
||||
<div key={item} className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 text-green-500" />
|
||||
<span className="text-sm leading-relaxed text-muted-foreground">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
<EmotionPicker />
|
||||
|
||||
<GalleryPreview slug={slug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroCard({ name, eventName, tasksCompleted }: { name: string; eventName: string; tasksCompleted: number }) {
|
||||
const progressMessage = tasksCompleted > 0
|
||||
? `Schon ${tasksCompleted} Aufgaben erledigt - weiter so!`
|
||||
: 'Starte mit deiner ersten Aufgabe - wir zählen auf dich!';
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-0 bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardDescription className="text-sm text-white/80">Willkommen zur Party</CardDescription>
|
||||
<CardTitle className="text-2xl font-bold">Hey {name}!</CardTitle>
|
||||
<p className="text-sm text-white/80">Du bist bereit für "{eventName}". Fang die Highlights des Events ein und teile sie mit allen Gästen.</p>
|
||||
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatTile({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white text-pink-600 shadow-sm">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||
<span className="text-lg font-semibold text-foreground">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLatestUpload(isoDate: string | null) {
|
||||
if (!isoDate) {
|
||||
return 'Noch kein Upload';
|
||||
}
|
||||
const date = new Date(isoDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'Noch kein Upload';
|
||||
}
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMinutes = Math.round(diffMs / 60000);
|
||||
if (diffMinutes < 1) {
|
||||
return 'Gerade eben';
|
||||
}
|
||||
if (diffMinutes < 60) {
|
||||
return `vor ${diffMinutes} Min`;
|
||||
}
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
if (diffHours < 24) {
|
||||
return `vor ${diffHours} Std`;
|
||||
}
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
return `vor ${diffDays} Tagen`;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Html5Qrcode } from 'html5-qrcode';
|
||||
import { readGuestName } from '../context/GuestIdentityContext';
|
||||
|
||||
export default function LandingPage() {
|
||||
const nav = useNavigate();
|
||||
const [slug, setSlug] = React.useState('');
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [slug, setSlug] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
|
||||
|
||||
async function join() {
|
||||
const s = slug.trim();
|
||||
async function join(eventSlug?: string) {
|
||||
const s = (eventSlug ?? slug).trim();
|
||||
if (!s) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -22,30 +27,131 @@ export default function LandingPage() {
|
||||
setError('Event nicht gefunden oder geschlossen.');
|
||||
return;
|
||||
}
|
||||
nav(`/e/${encodeURIComponent(s)}`);
|
||||
const storedName = readGuestName(s);
|
||||
if (!storedName) {
|
||||
nav(`/setup/${encodeURIComponent(s)}`);
|
||||
} else {
|
||||
nav(`/e/${encodeURIComponent(s)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Netzwerkfehler. Bitte später erneut versuchen.');
|
||||
console.error('Join request failed', e);
|
||||
setError('Netzwerkfehler. Bitte spaeter erneut versuchen.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const qrConfig = { fps: 10, qrbox: { width: 250, height: 250 } } as const;
|
||||
|
||||
async function startScanner() {
|
||||
if (scanner) {
|
||||
try {
|
||||
await scanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
|
||||
setIsScanning(true);
|
||||
} catch (err) {
|
||||
console.error('Scanner start failed', err);
|
||||
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newScanner = new Html5Qrcode('qr-reader');
|
||||
setScanner(newScanner);
|
||||
await newScanner.start({ facingMode: 'environment' }, qrConfig, onScanSuccess, () => undefined);
|
||||
setIsScanning(true);
|
||||
} catch (err) {
|
||||
console.error('Scanner initialisation failed', err);
|
||||
setError('Kamera-Zugriff fehlgeschlagen. Bitte erlaube den Zugriff und versuche es erneut.');
|
||||
}
|
||||
}
|
||||
|
||||
function stopScanner() {
|
||||
if (!scanner) {
|
||||
setIsScanning(false);
|
||||
return;
|
||||
}
|
||||
scanner
|
||||
.stop()
|
||||
.then(() => {
|
||||
setIsScanning(false);
|
||||
})
|
||||
.catch((err) => console.error('Scanner stop failed', err));
|
||||
}
|
||||
|
||||
async function onScanSuccess(decodedText: string) {
|
||||
const value = decodedText.trim();
|
||||
if (!value) return;
|
||||
await join(value);
|
||||
stopScanner();
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
if (scanner) {
|
||||
scanner.stop().catch(() => undefined);
|
||||
}
|
||||
}, [scanner]);
|
||||
|
||||
return (
|
||||
<Page title="Willkommen bei Fotochallenge 🎉">
|
||||
<Page title="Willkommen bei der Fotobox!">
|
||||
{error && (
|
||||
<Alert className="mb-3" variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="QR/PIN oder Event-Slug eingeben"
|
||||
/>
|
||||
<div className="h-3" />
|
||||
<Button disabled={loading || !slug.trim()} onClick={join}>
|
||||
{loading ? 'Prüfe…' : 'Event beitreten'}
|
||||
</Button>
|
||||
<div className="space-y-6 pb-20">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Willkommen bei der Fotobox!</h1>
|
||||
<p className="text-lg text-gray-600">Dein Schluessel zu unvergesslichen Momenten.</p>
|
||||
</div>
|
||||
|
||||
<Card className="mx-auto w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl font-semibold">Event beitreten</CardTitle>
|
||||
<CardDescription>Scanne den QR-Code oder gib den Code manuell ein.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-lg bg-gray-200">
|
||||
<svg className="h-16 w-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div id="qr-reader" className="w-full" hidden={!isScanning} />
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={isScanning ? stopScanner : startScanner}
|
||||
disabled={loading}
|
||||
>
|
||||
{isScanning ? 'Scanner stoppen' : 'QR-Code scannen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 py-2 text-center text-sm text-gray-500">
|
||||
Oder manuell eingeben
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(event) => setSlug(event.target.value)}
|
||||
placeholder="Event-Code eingeben"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 text-white hover:from-pink-600 hover:to-pink-700"
|
||||
disabled={loading || !slug.trim()}
|
||||
onClick={() => join()}
|
||||
>
|
||||
{loading ? 'Pruefe...' : 'Event beitreten'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
import { Page } from './_util';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { LegalMarkdown } from '../components/legal-markdown';
|
||||
|
||||
export default function LegalPage() {
|
||||
const { page } = useParams();
|
||||
@@ -9,42 +10,44 @@ export default function LegalPage() {
|
||||
const [body, setBody] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/v1/legal/${encodeURIComponent(page || '')}?lang=de`, { headers: { 'Cache-Control': 'no-store' }});
|
||||
if (res.ok) {
|
||||
const j = await res.json();
|
||||
setTitle(j.title || '');
|
||||
setBody(j.body_markdown || '');
|
||||
}
|
||||
setLoading(false);
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
if (page) load();
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadLegal() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch(`/api/v1/legal/${encodeURIComponent(page)}?lang=de`, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('failed');
|
||||
}
|
||||
const data = await res.json();
|
||||
setTitle(data.title || '');
|
||||
setBody(data.body_markdown || '');
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
console.error('Failed to load legal page', error);
|
||||
setTitle('');
|
||||
setBody('');
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadLegal();
|
||||
return () => controller.abort();
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<Page title={title || `Rechtliches: ${page}` }>
|
||||
{loading ? <p>Lädt…</p> : <Markdown md={body} />}
|
||||
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} />}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function Markdown({ md }: { md: string }) {
|
||||
// Tiny, safe Markdown: paragraphs + basic bold/italic + links; no external dependency
|
||||
const html = React.useMemo(() => {
|
||||
let s = md
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
// bold **text**
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// italic *text*
|
||||
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
|
||||
// links [text](url)
|
||||
s = s.replace(/\[(.+?)\]\((https?:[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1<\/a>');
|
||||
// paragraphs
|
||||
s = s.split(/\n{2,}/).map(p => `<p>${p.replace(/\n/g, '<br/>')}<\/p>`).join('\n');
|
||||
return s;
|
||||
}, [md]);
|
||||
return <div className="prose prose-sm dark:prose-invert" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,104 @@
|
||||
import React from 'react';
|
||||
import { Page } from './_util';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import Header from '../components/Header';
|
||||
|
||||
export default function ProfileSetupPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const nav = useNavigate();
|
||||
const { event, loading, error } = useEventData();
|
||||
const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
|
||||
const [name, setName] = useState(storedName);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) {
|
||||
nav('/');
|
||||
return;
|
||||
}
|
||||
}, [slug, nav]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated) {
|
||||
setName(storedName);
|
||||
}
|
||||
}, [hydrated, storedName]);
|
||||
|
||||
function handleChange(value: string) {
|
||||
setName(value);
|
||||
}
|
||||
|
||||
function submitName() {
|
||||
if (!slug) return;
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
persistName(trimmedName);
|
||||
nav(`/e/${slug}`);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Speichern des Namens:', e);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-32">
|
||||
<div className="text-lg">Lade Event...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !event) {
|
||||
return (
|
||||
<div className="text-center p-4">
|
||||
<p className="text-red-600 mb-4">{error || 'Event nicht gefunden.'}</p>
|
||||
<Button onClick={() => nav('/')}>Zurück zur Startseite</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title="Profil erstellen">
|
||||
<input placeholder="Dein Name" style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 8 }} />
|
||||
<div style={{ height: 12 }} />
|
||||
<button style={{ padding: '10px 16px', borderRadius: 8, background: '#111827', color: 'white' }}>Starten</button>
|
||||
</Page>
|
||||
<div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col">
|
||||
<Header slug={slug!} />
|
||||
<div className="flex-1 flex flex-col justify-center items-center px-4 py-8">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<CardTitle className="text-2xl font-bold text-gray-900">{event.name}</CardTitle>
|
||||
<CardDescription className="text-lg text-gray-600">
|
||||
Fange den schoensten Moment ein!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-sm font-medium">Dein Name (z.B. Anna)</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Dein Name"
|
||||
className="text-lg"
|
||||
disabled={submitting || !hydrated}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white py-3 text-base font-semibold rounded-xl"
|
||||
onClick={submitName}
|
||||
disabled={submitting || !name.trim() || !hydrated}
|
||||
>
|
||||
{submitting ? 'Speichere...' : "Let's go!"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Page } from './_util';
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppearance } from '../../hooks/use-appearance';
|
||||
import { Clock, RefreshCw, Smile } from 'lucide-react';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { EventData } from '../services/eventApi';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Sparkles, RefreshCw, Smile, Timer as TimerIcon, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
duration: number; // in minutes
|
||||
duration: number; // minutes
|
||||
emotion?: {
|
||||
slug: string;
|
||||
name: string;
|
||||
@@ -21,244 +19,508 @@ interface Task {
|
||||
is_completed: boolean;
|
||||
}
|
||||
|
||||
type EmotionOption = {
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const TASK_PROGRESS_TARGET = 5;
|
||||
const TIMER_VIBRATION = [0, 60, 120, 60];
|
||||
|
||||
export default function TaskPickerPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
// emotionSlug = searchParams.get('emotion'); // Temporär deaktiviert, da API-Filter nicht verfügbar
|
||||
const navigate = useNavigate();
|
||||
const { appearance } = useAppearance();
|
||||
const isDark = appearance === 'dark';
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [currentTask, setCurrentTask] = useState<Task | null>(null);
|
||||
const [timeLeft, setTimeLeft] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Timer state
|
||||
useEffect(() => {
|
||||
if (!currentTask) return;
|
||||
|
||||
const durationMs = currentTask.duration * 60 * 1000;
|
||||
setTimeLeft(durationMs / 1000);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(slug);
|
||||
|
||||
const [tasks, setTasks] = React.useState<Task[]>([]);
|
||||
const [currentTask, setCurrentTask] = React.useState<Task | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [selectedEmotion, setSelectedEmotion] = React.useState<string>('all');
|
||||
const [timeLeft, setTimeLeft] = React.useState<number>(0);
|
||||
const [timerRunning, setTimerRunning] = React.useState(false);
|
||||
const [timeUp, setTimeUp] = React.useState(false);
|
||||
const [isFetching, setIsFetching] = React.useState(false);
|
||||
|
||||
const recentTaskIdsRef = React.useRef<number[]>([]);
|
||||
const initialEmotionRef = React.useRef(false);
|
||||
|
||||
const fetchTasks = React.useCallback(async () => {
|
||||
if (!slug) return;
|
||||
setIsFetching(true);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/tasks`);
|
||||
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
|
||||
const payload = await response.json();
|
||||
if (Array.isArray(payload)) {
|
||||
setTasks(payload);
|
||||
} else {
|
||||
setTasks([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load tasks', err);
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
|
||||
setTasks([]);
|
||||
} finally {
|
||||
setIsFetching(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [fetchTasks]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialEmotionRef.current) return;
|
||||
const queryEmotion = searchParams.get('emotion');
|
||||
if (queryEmotion) {
|
||||
setSelectedEmotion(queryEmotion);
|
||||
}
|
||||
initialEmotionRef.current = true;
|
||||
}, [searchParams]);
|
||||
|
||||
const emotionOptions = React.useMemo<EmotionOption[]>(() => {
|
||||
const map = new Map<string, string>();
|
||||
tasks.forEach((task) => {
|
||||
if (task.emotion?.slug) {
|
||||
map.set(task.emotion.slug, task.emotion.name);
|
||||
}
|
||||
});
|
||||
return Array.from(map.entries()).map(([slugValue, name]) => ({ slug: slugValue, name }));
|
||||
}, [tasks]);
|
||||
|
||||
const filteredTasks = React.useMemo(() => {
|
||||
if (selectedEmotion === 'all') return tasks;
|
||||
return tasks.filter((task) => task.emotion?.slug === selectedEmotion);
|
||||
}, [tasks, selectedEmotion]);
|
||||
|
||||
const selectRandomTask = React.useCallback(
|
||||
(list: Task[]) => {
|
||||
if (!list.length) {
|
||||
setCurrentTask(null);
|
||||
return;
|
||||
}
|
||||
const avoidIds = recentTaskIdsRef.current;
|
||||
const available = list.filter((task) => !isCompleted(task.id));
|
||||
const base = available.length ? available : list;
|
||||
let candidates = base.filter((task) => !avoidIds.includes(task.id));
|
||||
if (!candidates.length) {
|
||||
candidates = base;
|
||||
}
|
||||
const chosen = candidates[Math.floor(Math.random() * candidates.length)];
|
||||
setCurrentTask(chosen);
|
||||
recentTaskIdsRef.current = [...avoidIds.filter((id) => id !== chosen.id), chosen.id].slice(-3);
|
||||
},
|
||||
[isCompleted]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!filteredTasks.length) {
|
||||
setCurrentTask(null);
|
||||
return;
|
||||
}
|
||||
if (!currentTask || !filteredTasks.some((task) => task.id === currentTask.id)) {
|
||||
selectRandomTask(filteredTasks);
|
||||
return;
|
||||
}
|
||||
const matchingTask = filteredTasks.find((task) => task.id === currentTask.id);
|
||||
const durationMinutes = matchingTask?.duration ?? currentTask.duration;
|
||||
setTimeLeft(durationMinutes * 60);
|
||||
setTimerRunning(false);
|
||||
setTimeUp(false);
|
||||
}, [filteredTasks, currentTask, selectRandomTask]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentTask) {
|
||||
setTimeLeft(0);
|
||||
setTimerRunning(false);
|
||||
setTimeUp(false);
|
||||
return;
|
||||
}
|
||||
setTimeLeft(currentTask.duration * 60);
|
||||
setTimerRunning(false);
|
||||
setTimeUp(false);
|
||||
}, [currentTask]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!timerRunning) return;
|
||||
if (timeLeft <= 0) {
|
||||
setTimerRunning(false);
|
||||
triggerTimeUp();
|
||||
return;
|
||||
}
|
||||
const tick = window.setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(interval);
|
||||
window.clearInterval(tick);
|
||||
triggerTimeUp();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(tick);
|
||||
}, [timerRunning, timeLeft]);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentTask]);
|
||||
|
||||
// Load tasks
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
|
||||
async function fetchTasks() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = `/api/v1/events/${slug}/tasks`;
|
||||
console.log('Fetching tasks from:', url); // Debug
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Tasks konnten nicht geladen werden');
|
||||
|
||||
const data = await response.json();
|
||||
setTasks(Array.isArray(data) ? data : []);
|
||||
|
||||
console.log('Loaded tasks:', data); // Debug
|
||||
|
||||
// Select random task
|
||||
if (data.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * data.length);
|
||||
setCurrentTask(data[randomIndex]);
|
||||
console.log('Selected random task:', data[randomIndex]); // Debug
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fetch tasks error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
function triggerTimeUp() {
|
||||
const supportsVibration = typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
|
||||
setTimerRunning(false);
|
||||
setTimeUp(true);
|
||||
if (supportsVibration) {
|
||||
try {
|
||||
navigator.vibrate(TIMER_VIBRATION);
|
||||
} catch (error) {
|
||||
console.warn('Vibration not permitted', error);
|
||||
}
|
||||
}
|
||||
window.setTimeout(() => setTimeUp(false), 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchTasks();
|
||||
}, [slug]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const formatTime = React.useCallback((seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
const secs = Math.max(0, seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}, []);
|
||||
|
||||
const progressRatio = currentTask ? Math.min(1, completedCount / TASK_PROGRESS_TARGET) : 0;
|
||||
|
||||
const handleSelectEmotion = (slugValue: string) => {
|
||||
setSelectedEmotion(slugValue);
|
||||
const next = new URLSearchParams(searchParams.toString());
|
||||
if (slugValue === 'all') {
|
||||
next.delete('emotion');
|
||||
} else {
|
||||
next.set('emotion', slugValue);
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const handleNewTask = () => {
|
||||
if (tasks.length === 0) return;
|
||||
const randomIndex = Math.floor(Math.random() * tasks.length);
|
||||
setCurrentTask(tasks[randomIndex]);
|
||||
setTimeLeft(tasks[randomIndex].duration * 60);
|
||||
selectRandomTask(filteredTasks);
|
||||
};
|
||||
|
||||
const handleStartTask = () => {
|
||||
const handleStartUpload = () => {
|
||||
if (!currentTask || !slug) return;
|
||||
navigate(`/e/${encodeURIComponent(slug)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
|
||||
};
|
||||
|
||||
const handleMarkCompleted = () => {
|
||||
if (!currentTask) return;
|
||||
// Navigate to upload with task context
|
||||
navigate(`/e/${slug}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
|
||||
markCompleted(currentTask.id);
|
||||
selectRandomTask(filteredTasks);
|
||||
};
|
||||
|
||||
const handleChangeMood = () => {
|
||||
navigate(`/e/${slug}`);
|
||||
const handleRetryFetch = () => {
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Page title="Aufgabe laden...">
|
||||
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 ${
|
||||
isDark ? 'text-white' : 'text-gray-900'
|
||||
}`}>
|
||||
<RefreshCw className="h-8 w-8 animate-spin mb-4" />
|
||||
<p className="text-sm">Lade Aufgabe...</p>
|
||||
</div>
|
||||
<BottomNav />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
const handleTimerToggle = () => {
|
||||
if (!currentTask) return;
|
||||
if (timerRunning) {
|
||||
setTimerRunning(false);
|
||||
setTimeLeft(currentTask.duration * 60);
|
||||
setTimeUp(false);
|
||||
} else {
|
||||
if (timeLeft <= 0) {
|
||||
setTimeLeft(currentTask.duration * 60);
|
||||
}
|
||||
setTimerRunning(true);
|
||||
setTimeUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error || !currentTask) {
|
||||
return (
|
||||
<Page title="Keine Aufgaben verfügbar">
|
||||
<div className={`flex flex-col items-center justify-center min-h-[60vh] p-4 space-y-4 ${
|
||||
isDark ? 'text-white' : 'text-gray-900'
|
||||
}`}>
|
||||
<Smile className="h-12 w-12 text-pink-500" />
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold mb-2">Keine passende Aufgabe gefunden</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{error || 'Für deine Stimmung gibt es derzeit keine Aufgaben. Versuche eine andere Stimmung oder warte auf neue Inhalte.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleChangeMood}
|
||||
className="w-full max-w-sm"
|
||||
>
|
||||
Andere Stimmung wählen
|
||||
</Button>
|
||||
</div>
|
||||
<BottomNav />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
const emptyState = !loading && (!filteredTasks.length || !currentTask);
|
||||
|
||||
return (
|
||||
<Page title={currentTask.title}>
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark ? 'bg-gray-900 text-white' : 'bg-white text-gray-900'
|
||||
}`}>
|
||||
{/* Task Header with Selfie Overlay */}
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="relative">
|
||||
{/* Selfie Placeholder */}
|
||||
<div className={`w-full aspect-square rounded-2xl bg-gradient-to-br ${
|
||||
isDark
|
||||
? 'from-gray-800 to-gray-700 shadow-2xl'
|
||||
: 'from-pink-50 to-pink-100 shadow-lg'
|
||||
} flex items-center justify-center`}>
|
||||
<div className="text-center space-y-2">
|
||||
<div className={`w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-2 ${
|
||||
isDark ? 'text-white' : 'text-gray-600'
|
||||
}`}>
|
||||
📸
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${
|
||||
isDark ? 'text-gray-300' : 'text-gray-600'
|
||||
}`}>
|
||||
Selfie-Vorschau
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
||||
timeLeft > 60
|
||||
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||
: timeLeft > 30
|
||||
? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
|
||||
: 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
} border`}>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTime(timeLeft)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-semibold text-foreground">Aufgabe auswaehlen</h1>
|
||||
<Badge variant="secondary" className="whitespace-nowrap">
|
||||
Schon {completedCount} Aufgaben erledigt
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/40 p-4">
|
||||
<div className="flex items-center justify-between text-sm font-medium text-muted-foreground">
|
||||
<span>Auf dem Weg zum naechsten Erfolg</span>
|
||||
<span>
|
||||
{completedCount >= TASK_PROGRESS_TARGET
|
||||
? 'Stark!'
|
||||
: `${Math.max(0, TASK_PROGRESS_TARGET - completedCount)} bis zum Badge`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-2 rounded-full bg-gradient-to-r from-pink-500 to-purple-500 transition-all"
|
||||
style={{ width: `${progressRatio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{emotionOptions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EmotionChip
|
||||
active={selectedEmotion === 'all'}
|
||||
label="Alle Stimmungen"
|
||||
onClick={() => handleSelectEmotion('all')}
|
||||
/>
|
||||
{emotionOptions.map((emotion) => (
|
||||
<EmotionChip
|
||||
key={emotion.slug}
|
||||
active={selectedEmotion === emotion.slug}
|
||||
label={emotion.name}
|
||||
onClick={() => handleSelectEmotion(emotion.slug)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Task Description Overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-transparent to-transparent p-4 rounded-b-2xl">
|
||||
<div className="space-y-2">
|
||||
<h1 className={`text-xl font-bold ${
|
||||
isDark ? 'text-gray-100' : 'text-white'
|
||||
}`}>
|
||||
{currentTask.title}
|
||||
</h1>
|
||||
<p className={`text-sm leading-relaxed ${
|
||||
isDark ? 'text-gray-200' : 'text-gray-100'
|
||||
}`}>
|
||||
{currentTask.description}
|
||||
</p>
|
||||
{currentTask.instructions && (
|
||||
<div className={`p-2 rounded-lg ${
|
||||
isDark
|
||||
? 'bg-gray-700/80 text-gray-100'
|
||||
: 'bg-gray-800/80 text-white border border-gray-600/50'
|
||||
}`}>
|
||||
<p className="text-xs italic">💡 {currentTask.instructions}</p>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="space-y-4">
|
||||
<SkeletonBlock />
|
||||
<SkeletonBlock />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="flex items-center justify-between gap-3">
|
||||
<span>{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={handleRetryFetch} disabled={isFetching}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{emptyState && (
|
||||
<EmptyState
|
||||
hasTasks={Boolean(tasks.length)}
|
||||
onRetry={handleRetryFetch}
|
||||
emotionOptions={emotionOptions}
|
||||
onEmotionSelect={handleSelectEmotion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!emptyState && currentTask && (
|
||||
<div className="space-y-6">
|
||||
<article className="overflow-hidden rounded-2xl border bg-card">
|
||||
<div className="relative">
|
||||
<div className="flex aspect-video items-center justify-center bg-gradient-to-br from-pink-500/80 via-purple-500/60 to-indigo-500/60 text-white">
|
||||
<div className="text-center">
|
||||
<Sparkles className="mx-auto mb-3 h-10 w-10" />
|
||||
<p className="text-sm uppercase tracking-[.2em]">Deine Mission</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold">{currentTask.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-3 top-3 flex flex-col items-end gap-2">
|
||||
<BadgeTimer
|
||||
label="Countdown"
|
||||
value={formatTime(timeLeft)}
|
||||
tone={timerTone(timeLeft, currentTask.duration)}
|
||||
/>
|
||||
{timeUp && (
|
||||
<Badge variant="destructive" className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Zeit abgelaufen!
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
<Button
|
||||
onClick={handleStartTask}
|
||||
className="w-full h-14 bg-gradient-to-r from-pink-500 to-pink-600 hover:from-pink-600 hover:to-pink-700 text-white rounded-xl text-base font-semibold shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
📸 Los geht's
|
||||
</span>
|
||||
</Button>
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline" className="flex items-center gap-2">
|
||||
<TimerIcon className="h-4 w-4" />
|
||||
{currentTask.duration} Min
|
||||
</Badge>
|
||||
{currentTask.emotion?.name && (
|
||||
<Badge variant="outline" className="flex items-center gap-2">
|
||||
<Smile className="h-4 w-4" />
|
||||
{currentTask.emotion.name}
|
||||
</Badge>
|
||||
)}
|
||||
{isCompleted(currentTask.id) && (
|
||||
<Badge variant="secondary" className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Bereits erledigt
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleNewTask}
|
||||
className="flex-1 h-12 border-gray-300 dark:border-gray-600 text-sm rounded-xl transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Neue Aufgabe
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{currentTask.description}</p>
|
||||
|
||||
{currentTask.instructions && (
|
||||
<div className="rounded-xl border border-dashed border-pink-200 bg-pink-50/60 p-4 text-sm font-medium text-pink-800 dark:border-pink-500/40 dark:bg-pink-500/10 dark:text-pink-100">
|
||||
{currentTask.instructions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2 text-sm">
|
||||
<ChecklistItem text="Stimme dich auf die Aufgabe ein." />
|
||||
<ChecklistItem text="Hol dir dein Team oder Motiv ins Bild." />
|
||||
<ChecklistItem text="Halte Emotion und Aufgabe im Foto fest." />
|
||||
</ul>
|
||||
|
||||
{timerRunning && currentTask.duration > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Countdown</span>
|
||||
<span>Restzeit: {formatTime(timeLeft)}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-2 rounded-full bg-gradient-to-r from-amber-400 to-rose-500 transition-all"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, (timeLeft / (currentTask.duration * 60)) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button onClick={handleStartUpload} className="h-14 text-base font-semibold">
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
Los geht's
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleChangeMood}
|
||||
className="flex-1 h-12 text-sm rounded-xl transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
<Button
|
||||
variant={timerRunning ? 'destructive' : 'outline'}
|
||||
onClick={handleTimerToggle}
|
||||
className="h-14 text-base"
|
||||
>
|
||||
<Smile className="h-4 w-4 mr-2" />
|
||||
Andere Stimmung
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<TimerIcon className="h-5 w-5" />
|
||||
{timerRunning ? 'Timer stoppen' : 'Timer starten'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button variant="secondary" onClick={handleMarkCompleted} className="h-12">
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
Aufgabe erledigt
|
||||
</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleNewTask} className="h-12">
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
Neue Aufgabe anzeigen
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Navigation */}
|
||||
<BottomNav />
|
||||
</div>
|
||||
</Page>
|
||||
{!loading && !tasks.length && !error && (
|
||||
<Alert>
|
||||
<AlertDescription>Fuer dieses Event sind derzeit keine Aufgaben hinterlegt.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timerTone(timeLeft: number, durationMinutes: number) {
|
||||
const totalSeconds = Math.max(1, durationMinutes * 60);
|
||||
const ratio = timeLeft / totalSeconds;
|
||||
if (ratio > 0.5) return 'okay';
|
||||
if (ratio > 0.25) return 'warm';
|
||||
return 'hot';
|
||||
}
|
||||
|
||||
function EmotionChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded-full border px-4 py-1 text-sm transition ${
|
||||
active
|
||||
? 'border-pink-500 bg-pink-500 text-white shadow-sm'
|
||||
: 'border-border bg-background text-muted-foreground hover:border-pink-400 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecklistItem({ text }: { text: string }) {
|
||||
return (
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 text-emerald-500" />
|
||||
<span className="text-muted-foreground">{text}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeTimer({ label, value, tone }: { label: string; value: string; tone: 'okay' | 'warm' | 'hot' }) {
|
||||
const toneClasses = {
|
||||
okay: 'bg-emerald-500/15 text-emerald-500 border-emerald-500/30',
|
||||
warm: 'bg-amber-500/15 text-amber-500 border-amber-500/30',
|
||||
hot: 'bg-rose-500/15 text-rose-500 border-rose-500/30',
|
||||
}[tone];
|
||||
return (
|
||||
<div className={`flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium ${toneClasses}`}>
|
||||
<TimerIcon className="h-3.5 w-3.5" />
|
||||
<span>{label}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonBlock() {
|
||||
return <div className="h-24 animate-pulse rounded-xl bg-muted/60" />;
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
hasTasks,
|
||||
onRetry,
|
||||
emotionOptions,
|
||||
onEmotionSelect,
|
||||
}: {
|
||||
hasTasks: boolean;
|
||||
onRetry: () => void;
|
||||
emotionOptions: EmotionOption[];
|
||||
onEmotionSelect: (slug: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-muted-foreground/30 bg-muted/20 p-8 text-center">
|
||||
<Smile className="h-12 w-12 text-pink-500" />
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">Keine passende Aufgabe gefunden</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasTasks
|
||||
? 'Fuer deine aktuelle Stimmung gibt es gerade keine Aufgabe. Waehle eine andere Stimmung oder lade neue Aufgaben.'
|
||||
: 'Hier sind noch keine Aufgaben hinterlegt. Bitte versuche es spaeter erneut.'}
|
||||
</p>
|
||||
</div>
|
||||
{hasTasks && emotionOptions.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{emotionOptions.map((emotion) => (
|
||||
<EmotionChip
|
||||
key={emotion.slug}
|
||||
label={emotion.name}
|
||||
active={false}
|
||||
onClick={() => onEmotionSelect(emotion.slug)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button onClick={onRetry} variant="outline" className="mt-2">
|
||||
Aufgaben neu laden
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,9 @@ export function usePollGalleryDelta(slug: string) {
|
||||
const [newCount, setNewCount] = useState(0);
|
||||
const latestAt = useRef<string | null>(null);
|
||||
const timer = useRef<number | null>(null);
|
||||
const [visible, setVisible] = useState(
|
||||
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
|
||||
);
|
||||
|
||||
async function fetchDelta() {
|
||||
try {
|
||||
@@ -75,16 +78,25 @@ export function usePollGalleryDelta(slug: string) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onVis = () => setVisible(document.visibilityState === 'visible');
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
return () => document.removeEventListener('visibilitychange', onVis);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
latestAt.current = null;
|
||||
setPhotos([]);
|
||||
fetchDelta();
|
||||
timer.current = window.setInterval(fetchDelta, 30_000);
|
||||
if (timer.current) window.clearInterval(timer.current);
|
||||
// Poll less aggressively when hidden
|
||||
const interval = visible ? 30_000 : 90_000;
|
||||
timer.current = window.setInterval(fetchDelta, interval);
|
||||
return () => {
|
||||
if (timer.current) window.clearInterval(timer.current);
|
||||
};
|
||||
}, [slug]);
|
||||
}, [slug, visible]);
|
||||
|
||||
function acknowledgeNew() { setNewCount(0); }
|
||||
return { loading, photos, newCount, acknowledgeNew };
|
||||
|
||||
@@ -1,39 +1,67 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Stats = { onlineGuests: number; tasksSolved: number; latestPhotoAt?: string };
|
||||
export type EventStats = {
|
||||
onlineGuests: number;
|
||||
tasksSolved: number;
|
||||
latestPhotoAt: string | null;
|
||||
};
|
||||
|
||||
export function usePollStats(slug: string) {
|
||||
const [data, setData] = useState<Stats | null>(null);
|
||||
type StatsResponse = {
|
||||
online_guests?: number;
|
||||
tasks_solved?: number;
|
||||
latest_photo_at?: string;
|
||||
};
|
||||
|
||||
export function usePollStats(slug: string | null | undefined) {
|
||||
const [data, setData] = useState<EventStats>({ onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const timer = useRef<number | null>(null);
|
||||
const visible = typeof document !== 'undefined' ? document.visibilityState === 'visible' : true;
|
||||
const [visible, setVisible] = useState(
|
||||
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
|
||||
);
|
||||
|
||||
async function fetchOnce() {
|
||||
const canPoll = Boolean(slug);
|
||||
|
||||
async function fetchOnce(activeSlug: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/stats`, {
|
||||
const res = await fetch(`/api/v1/events/${encodeURIComponent(activeSlug)}/stats`, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
if (res.status === 304) return;
|
||||
const json = await res.json();
|
||||
setData({ onlineGuests: json.online_guests ?? 0, tasksSolved: json.tasks_solved ?? 0, latestPhotoAt: json.latest_photo_at });
|
||||
const json: StatsResponse = await res.json();
|
||||
setData({
|
||||
onlineGuests: json.online_guests ?? 0,
|
||||
tasksSolved: json.tasks_solved ?? 0,
|
||||
latestPhotoAt: json.latest_photo_at ?? null,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchOnce();
|
||||
function schedule() {
|
||||
if (!visible) return;
|
||||
timer.current = window.setInterval(fetchOnce, 10_000);
|
||||
const onVis = () => setVisible(document.visibilityState === 'visible');
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
return () => document.removeEventListener('visibilitychange', onVis);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canPoll) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const activeSlug = String(slug);
|
||||
fetchOnce(activeSlug);
|
||||
if (timer.current) window.clearInterval(timer.current);
|
||||
if (visible) {
|
||||
timer.current = window.setInterval(() => fetchOnce(activeSlug), 10_000);
|
||||
}
|
||||
schedule();
|
||||
return () => {
|
||||
if (timer.current) window.clearInterval(timer.current);
|
||||
};
|
||||
}, [slug, visible]);
|
||||
}, [slug, visible, canPoll]);
|
||||
|
||||
return { loading, onlineGuests: data?.onlineGuests ?? 0, tasksSolved: data?.tasksSolved ?? 0 };
|
||||
return { ...data, loading };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { withStore } from './idb';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
import { createUpload } from './xhr';
|
||||
import { notify } from './notify';
|
||||
type SyncManager = { register(tag: string): Promise<void>; };
|
||||
|
||||
export type QueueItem = {
|
||||
id?: number;
|
||||
@@ -26,7 +27,10 @@ export async function enqueue(item: Omit<QueueItem, 'id' | 'status' | 'retries'
|
||||
});
|
||||
// Register background sync if available
|
||||
if ('serviceWorker' in navigator && 'SyncManager' in window) {
|
||||
try { const reg = await navigator.serviceWorker.ready; await reg.sync.register('upload-queue'); } catch {}
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
(reg as ServiceWorkerRegistration & { sync?: SyncManager }).sync?.register('upload-queue');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import React from 'react';
|
||||
import { createBrowserRouter, Outlet, useParams } from 'react-router-dom';
|
||||
import Header from './components/Header';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import { EventStatsProvider } from './context/EventStatsContext';
|
||||
import { GuestIdentityProvider } from './context/GuestIdentityContext';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import ProfileSetupPage from './pages/ProfileSetupPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
@@ -19,20 +21,43 @@ import NotFoundPage from './pages/NotFoundPage';
|
||||
|
||||
function HomeLayout() {
|
||||
const { slug } = useParams();
|
||||
return (
|
||||
<div className="pb-16">
|
||||
{slug ? <Header slug={slug} /> : <Header title="Event" />}
|
||||
<div className="px-4 py-3">
|
||||
<Outlet />
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header title="Event" />
|
||||
<div className="px-4 py-3">
|
||||
<Outlet />
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GuestIdentityProvider slug={slug}>
|
||||
<EventStatsProvider slug={slug}>
|
||||
<div className="pb-16">
|
||||
<Header slug={slug} />
|
||||
<div className="px-4 py-3">
|
||||
<Outlet />
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
</EventStatsProvider>
|
||||
</GuestIdentityProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/', element: <SimpleLayout title="Fotospiel"><LandingPage /></SimpleLayout> },
|
||||
{ path: '/setup', element: <SimpleLayout title="Profil"><ProfileSetupPage /></SimpleLayout> },
|
||||
{
|
||||
path: '/setup/:slug',
|
||||
element: <SetupLayout />,
|
||||
children: [
|
||||
{ index: true, element: <ProfileSetupPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/e/:slug',
|
||||
element: <HomeLayout />,
|
||||
@@ -53,6 +78,21 @@ export const router = createBrowserRouter([
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
]);
|
||||
|
||||
function SetupLayout() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
if (!slug) return null;
|
||||
return (
|
||||
<GuestIdentityProvider slug={slug}>
|
||||
<EventStatsProvider slug={slug}>
|
||||
<div className="pb-0">
|
||||
<Header slug={slug} />
|
||||
<Outlet />
|
||||
</div>
|
||||
</EventStatsProvider>
|
||||
</GuestIdentityProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
@@ -64,3 +104,4 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
212
resources/js/guest/services/achievementApi.ts
Normal file
212
resources/js/guest/services/achievementApi.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
export interface AchievementBadge {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
earned: boolean;
|
||||
progress: number;
|
||||
target: number;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
guest: string;
|
||||
photos: number;
|
||||
likes: number;
|
||||
}
|
||||
|
||||
export interface TopPhotoHighlight {
|
||||
photoId: number;
|
||||
guest: string;
|
||||
likes: number;
|
||||
task?: string | null;
|
||||
createdAt: string;
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
export interface TrendingEmotionHighlight {
|
||||
emotionId: number;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TimelinePoint {
|
||||
date: string;
|
||||
photos: number;
|
||||
guests: number;
|
||||
}
|
||||
|
||||
export interface FeedEntry {
|
||||
photoId: number;
|
||||
guest: string;
|
||||
task?: string | null;
|
||||
likes: number;
|
||||
createdAt: string;
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
export interface AchievementsPayload {
|
||||
summary: {
|
||||
totalPhotos: number;
|
||||
uniqueGuests: number;
|
||||
tasksSolved: number;
|
||||
likesTotal: number;
|
||||
};
|
||||
personal: {
|
||||
guestName: string;
|
||||
photos: number;
|
||||
tasks: number;
|
||||
likes: number;
|
||||
badges: AchievementBadge[];
|
||||
} | null;
|
||||
leaderboards: {
|
||||
uploads: LeaderboardEntry[];
|
||||
likes: LeaderboardEntry[];
|
||||
};
|
||||
highlights: {
|
||||
topPhoto: TopPhotoHighlight | null;
|
||||
trendingEmotion: TrendingEmotionHighlight | null;
|
||||
timeline: TimelinePoint[];
|
||||
};
|
||||
feed: FeedEntry[];
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function safeString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
export async function fetchAchievements(
|
||||
slug: string,
|
||||
guestName?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<AchievementsPayload> {
|
||||
const params = new URLSearchParams();
|
||||
if (guestName && guestName.trim().length > 0) {
|
||||
params.set('guest_name', guestName.trim());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/achievements?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Device-Id': getDeviceId(),
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || 'Achievements request failed');
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const summary = json.summary ?? {};
|
||||
const personalRaw = json.personal ?? null;
|
||||
const leaderboards = json.leaderboards ?? {};
|
||||
const highlights = json.highlights ?? {};
|
||||
const feedRaw = Array.isArray(json.feed) ? json.feed : [];
|
||||
|
||||
const personal = personalRaw
|
||||
? {
|
||||
guestName: safeString(personalRaw.guest_name),
|
||||
photos: toNumber(personalRaw.photos),
|
||||
tasks: toNumber(personalRaw.tasks),
|
||||
likes: toNumber(personalRaw.likes),
|
||||
badges: Array.isArray(personalRaw.badges)
|
||||
? personalRaw.badges.map((badge: any): AchievementBadge => ({
|
||||
id: safeString(badge.id),
|
||||
title: safeString(badge.title),
|
||||
description: safeString(badge.description),
|
||||
earned: Boolean(badge.earned),
|
||||
progress: toNumber(badge.progress),
|
||||
target: toNumber(badge.target, 1),
|
||||
}))
|
||||
: [],
|
||||
}
|
||||
: null;
|
||||
|
||||
const uploadsBoard = Array.isArray(leaderboards.uploads)
|
||||
? leaderboards.uploads.map((row: any): LeaderboardEntry => ({
|
||||
guest: safeString(row.guest),
|
||||
photos: toNumber(row.photos),
|
||||
likes: toNumber(row.likes),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const likesBoard = Array.isArray(leaderboards.likes)
|
||||
? leaderboards.likes.map((row: any): LeaderboardEntry => ({
|
||||
guest: safeString(row.guest),
|
||||
photos: toNumber(row.photos),
|
||||
likes: toNumber(row.likes),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const topPhotoRaw = highlights.top_photo ?? null;
|
||||
const topPhoto = topPhotoRaw
|
||||
? {
|
||||
photoId: toNumber(topPhotoRaw.photo_id),
|
||||
guest: safeString(topPhotoRaw.guest),
|
||||
likes: toNumber(topPhotoRaw.likes),
|
||||
task: topPhotoRaw.task ?? null,
|
||||
createdAt: safeString(topPhotoRaw.created_at),
|
||||
thumbnail: topPhotoRaw.thumbnail ? safeString(topPhotoRaw.thumbnail) : null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const trendingRaw = highlights.trending_emotion ?? null;
|
||||
const trendingEmotion = trendingRaw
|
||||
? {
|
||||
emotionId: toNumber(trendingRaw.emotion_id),
|
||||
name: safeString(trendingRaw.name),
|
||||
count: toNumber(trendingRaw.count),
|
||||
}
|
||||
: null;
|
||||
|
||||
const timeline = Array.isArray(highlights.timeline)
|
||||
? highlights.timeline.map((row: any): TimelinePoint => ({
|
||||
date: safeString(row.date),
|
||||
photos: toNumber(row.photos),
|
||||
guests: toNumber(row.guests),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const feed = feedRaw.map((row: any): FeedEntry => ({
|
||||
photoId: toNumber(row.photo_id),
|
||||
guest: safeString(row.guest),
|
||||
task: row.task ?? null,
|
||||
likes: toNumber(row.likes),
|
||||
createdAt: safeString(row.created_at),
|
||||
thumbnail: row.thumbnail ? safeString(row.thumbnail) : null,
|
||||
}));
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalPhotos: toNumber(summary.total_photos),
|
||||
uniqueGuests: toNumber(summary.unique_guests),
|
||||
tasksSolved: toNumber(summary.tasks_solved),
|
||||
likesTotal: toNumber(summary.likes_total),
|
||||
},
|
||||
personal,
|
||||
leaderboards: {
|
||||
uploads: uploadsBoard,
|
||||
likes: likesBoard,
|
||||
},
|
||||
highlights: {
|
||||
topPhoto,
|
||||
trendingEmotion,
|
||||
timeline,
|
||||
},
|
||||
feed,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user