Add api key and sorting on scoreboard
This commit is contained in:
parent
36b686bbd4
commit
4688a73559
14 changed files with 791 additions and 227 deletions
|
|
@ -1,9 +1,14 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Key, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { X, Key, Eye, EyeOff, Loader2, ChevronDown, Search } from 'lucide-react';
|
||||
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||
import type { AIProvider, UserPreferences } from '../types';
|
||||
|
||||
interface OpenAIModel {
|
||||
id: string;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
interface ApiKeyModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -24,28 +29,96 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
|||
useBodyScrollLock(isOpen);
|
||||
const [localProvider, setLocalProvider] = useState<AIProvider>(preferences.aiProvider || 'gemini');
|
||||
const [localGeminiKey, setLocalGeminiKey] = useState(preferences.geminiApiKey || '');
|
||||
const [localGeminiModel, setLocalGeminiModel] = useState(preferences.geminiModel || '');
|
||||
const [localOpenRouterKey, setLocalOpenRouterKey] = useState(preferences.openRouterApiKey || '');
|
||||
const [localOpenRouterModel, setLocalOpenRouterModel] = useState(preferences.openRouterModel || '');
|
||||
const [localOpenAIKey, setLocalOpenAIKey] = useState(preferences.openAIApiKey || '');
|
||||
const [localOpenAIModel, setLocalOpenAIModel] = useState(preferences.openAIModel || '');
|
||||
const [showGeminiKey, setShowGeminiKey] = useState(false);
|
||||
const [showOpenRouterKey, setShowOpenRouterKey] = useState(false);
|
||||
const [showOpenAIKey, setShowOpenAIKey] = useState(false);
|
||||
|
||||
const [openAIModels, setOpenAIModels] = useState<OpenAIModel[]>([]);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [modelSearchQuery, setModelSearchQuery] = useState('');
|
||||
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
||||
const modelDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalProvider(preferences.aiProvider || 'gemini');
|
||||
setLocalGeminiKey(preferences.geminiApiKey || '');
|
||||
setLocalGeminiModel(preferences.geminiModel || '');
|
||||
setLocalOpenRouterKey(preferences.openRouterApiKey || '');
|
||||
setLocalOpenRouterModel(preferences.openRouterModel || '');
|
||||
setLocalOpenAIKey(preferences.openAIApiKey || '');
|
||||
setLocalOpenAIModel(preferences.openAIModel || '');
|
||||
setModelSearchQuery('');
|
||||
setIsModelDropdownOpen(false);
|
||||
}
|
||||
}, [isOpen, preferences]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOpenAIModels = async () => {
|
||||
if (!localOpenAIKey || localOpenAIKey.length < 10) {
|
||||
setOpenAIModels([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingModels(true);
|
||||
try {
|
||||
const response = await fetch('https://api.openai.com/v1/models', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localOpenAIKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const models = (data.data as (OpenAIModel & { created: number })[])
|
||||
.filter(m => !m.id.includes('realtime') && !m.id.includes('audio') && !m.id.includes('tts') && !m.id.includes('whisper') && !m.id.includes('dall-e') && !m.id.includes('embedding'))
|
||||
.sort((a, b) => b.created - a.created);
|
||||
setOpenAIModels(models);
|
||||
} else {
|
||||
setOpenAIModels([]);
|
||||
}
|
||||
} catch {
|
||||
setOpenAIModels([]);
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounceTimer = setTimeout(fetchOpenAIModels, 500);
|
||||
return () => clearTimeout(debounceTimer);
|
||||
}, [localOpenAIKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modelDropdownRef.current && !modelDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsModelDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const filteredModels = openAIModels.filter(model =>
|
||||
model.id.toLowerCase().includes(modelSearchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
await onSave({
|
||||
aiProvider: localProvider,
|
||||
geminiApiKey: localGeminiKey || undefined,
|
||||
geminiModel: localGeminiModel || undefined,
|
||||
openRouterApiKey: localOpenRouterKey || undefined,
|
||||
openRouterModel: localOpenRouterModel || undefined,
|
||||
openAIApiKey: localOpenAIKey || undefined,
|
||||
openAIModel: localOpenAIModel || undefined,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
|
@ -62,10 +135,10 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
|||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="bg-white rounded-2xl max-w-md w-full shadow-xl overflow-hidden"
|
||||
className="bg-white rounded-2xl max-w-md w-full shadow-xl overflow-visible"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-gray-800 to-gray-900">
|
||||
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-gradient-to-r from-gray-800 to-gray-900 rounded-t-2xl">
|
||||
<div className="flex items-center gap-3 text-white">
|
||||
<div className="p-2 bg-white/20 rounded-xl">
|
||||
<Key size={24} />
|
||||
|
|
@ -84,15 +157,13 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="p-6 bg-gray-50 space-y-4">
|
||||
{hasAIAccess ? (
|
||||
<>
|
||||
<div>
|
||||
<div>
|
||||
<label className="block font-bold text-gray-800 mb-2">AI Provider</label>
|
||||
<div className="flex bg-gray-200 p-1 rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocalProvider('gemini')}
|
||||
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all ${
|
||||
className={`flex-1 py-2 px-2 rounded-lg font-bold text-sm transition-all ${
|
||||
localProvider === 'gemini'
|
||||
? 'bg-white shadow-sm text-gray-800'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
|
|
@ -100,10 +171,21 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
|||
>
|
||||
Gemini
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocalProvider('openai')}
|
||||
className={`flex-1 py-2 px-2 rounded-lg font-bold text-sm transition-all ${
|
||||
localProvider === 'openai'
|
||||
? 'bg-white shadow-sm text-gray-800'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
OpenAI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocalProvider('openrouter')}
|
||||
className={`flex-1 py-2 px-3 rounded-lg font-bold text-sm transition-all ${
|
||||
className={`flex-1 py-2 px-2 rounded-lg font-bold text-sm transition-all ${
|
||||
localProvider === 'openrouter'
|
||||
? 'bg-white shadow-sm text-gray-800'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
|
|
@ -114,32 +196,164 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{localProvider === 'gemini' ? (
|
||||
<div>
|
||||
<label className="block font-bold text-gray-800 mb-2">
|
||||
Gemini API Key
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Leave empty to use the system key.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showGeminiKey ? 'text' : 'password'}
|
||||
value={localGeminiKey}
|
||||
onChange={(e) => setLocalGeminiKey(e.target.value)}
|
||||
placeholder="AIza..."
|
||||
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGeminiKey(!showGeminiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showGeminiKey ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
{localProvider === 'gemini' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block font-bold text-gray-800 mb-2">
|
||||
Gemini API Key
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Get your key from{' '}
|
||||
<a
|
||||
href="https://aistudio.google.com/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-theme-primary hover:underline"
|
||||
>
|
||||
aistudio.google.com/apikey
|
||||
</a>
|
||||
{hasAIAccess ? ' or leave empty to use the system key.' : '.'}
|
||||
</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showGeminiKey ? 'text' : 'password'}
|
||||
value={localGeminiKey}
|
||||
onChange={(e) => setLocalGeminiKey(e.target.value)}
|
||||
placeholder="AIza..."
|
||||
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGeminiKey(!showGeminiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showGeminiKey ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block font-bold text-gray-800 mb-2">
|
||||
Model
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Default: gemini-3-flash-preview
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={localGeminiModel}
|
||||
onChange={(e) => setLocalGeminiModel(e.target.value)}
|
||||
placeholder="gemini-3-flash-preview"
|
||||
className="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{localProvider === 'openai' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block font-bold text-gray-800 mb-2">
|
||||
OpenAI API Key
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Get your key from{' '}
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-theme-primary hover:underline"
|
||||
>
|
||||
platform.openai.com/api-keys
|
||||
</a>
|
||||
</p>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showOpenAIKey ? 'text' : 'password'}
|
||||
value={localOpenAIKey}
|
||||
onChange={(e) => setLocalOpenAIKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="w-full px-4 py-3 pr-12 rounded-xl border-2 border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOpenAIKey(!showOpenAIKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showOpenAIKey ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-bold text-gray-800 mb-2">
|
||||
Model
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
{localOpenAIKey ? 'Select a model from your account' : 'Enter API key to load models'}
|
||||
</p>
|
||||
<div className="relative" ref={modelDropdownRef}>
|
||||
<div
|
||||
className={`w-full px-4 py-3 rounded-xl border-2 flex items-center justify-between cursor-pointer transition-colors ${
|
||||
isModelDropdownOpen ? 'border-theme-primary' : 'border-gray-200'
|
||||
} ${!localOpenAIKey ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'}`}
|
||||
onClick={() => localOpenAIKey && setIsModelDropdownOpen(!isModelDropdownOpen)}
|
||||
>
|
||||
<span className={localOpenAIModel ? 'text-gray-800' : 'text-gray-400'}>
|
||||
{localOpenAIModel || 'gpt-5-mini'}
|
||||
</span>
|
||||
{loadingModels ? (
|
||||
<Loader2 size={18} className="animate-spin text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown size={18} className={`text-gray-400 transition-transform ${isModelDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isModelDropdownOpen && openAIModels.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border-2 border-gray-200 rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="p-2 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={modelSearchQuery}
|
||||
onChange={(e) => setModelSearchQuery(e.target.value)}
|
||||
placeholder="Search models..."
|
||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-gray-200 focus:border-theme-primary focus:outline-none text-gray-800"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredModels.length > 0 ? (
|
||||
filteredModels.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
className={`w-full px-4 py-2 text-left text-sm hover:bg-gray-50 transition-colors ${
|
||||
localOpenAIModel === model.id ? 'bg-theme-primary/10 text-theme-primary font-medium' : 'text-gray-700'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setLocalOpenAIModel(model.id);
|
||||
setIsModelDropdownOpen(false);
|
||||
setModelSearchQuery('');
|
||||
}}
|
||||
>
|
||||
{model.id}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">
|
||||
No models found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{localProvider === 'openrouter' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block font-bold text-gray-800 mb-2">
|
||||
|
|
@ -190,14 +404,6 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<Key size={32} className="mx-auto mb-3 text-gray-400" />
|
||||
<p className="text-gray-500 font-bold">API access not available</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Contact an administrator for access</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-100 flex gap-3">
|
||||
|
|
@ -207,22 +413,20 @@ export const ApiKeyModal: React.FC<ApiKeyModalProps> = ({
|
|||
>
|
||||
Cancel
|
||||
</button>
|
||||
{hasAIAccess && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 py-3 rounded-xl font-bold bg-gray-800 text-white hover:bg-gray-700 transition disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 py-3 rounded-xl font-bold bg-gray-800 text-white hover:bg-gray-700 transition disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ interface LandingProps {
|
|||
useOcr?: boolean;
|
||||
aiProvider?: AIProvider;
|
||||
apiKey?: string;
|
||||
geminiModel?: string;
|
||||
openRouterModel?: string;
|
||||
openAIModel?: string;
|
||||
}) => void;
|
||||
onCreateManual: () => void;
|
||||
onLoadQuiz: (quiz: Quiz, quizId?: string) => void;
|
||||
|
|
@ -113,9 +115,11 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
|
||||
const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig();
|
||||
const { preferences, hasAIAccess, saving: savingPrefs, savePreferences, applyColorScheme } = useUserPreferences();
|
||||
const hasValidApiKey = preferences.aiProvider === 'openrouter'
|
||||
? !!preferences.openRouterApiKey
|
||||
: (hasAIAccess || !!preferences.geminiApiKey);
|
||||
const hasValidApiKey = (() => {
|
||||
if (preferences.aiProvider === 'openrouter') return !!preferences.openRouterApiKey;
|
||||
if (preferences.aiProvider === 'openai') return !!preferences.openAIApiKey;
|
||||
return hasAIAccess || !!preferences.geminiApiKey;
|
||||
})();
|
||||
const canUseAI = auth.isAuthenticated && hasValidApiKey;
|
||||
|
||||
const {
|
||||
|
|
@ -219,9 +223,14 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
e.preventDefault();
|
||||
if (canGenerate && !isLoading) {
|
||||
const aiProvider = preferences.aiProvider || 'gemini';
|
||||
const apiKey = aiProvider === 'openrouter'
|
||||
? preferences.openRouterApiKey
|
||||
: preferences.geminiApiKey;
|
||||
let apiKey: string | undefined;
|
||||
if (aiProvider === 'openrouter') {
|
||||
apiKey = preferences.openRouterApiKey;
|
||||
} else if (aiProvider === 'openai') {
|
||||
apiKey = preferences.openAIApiKey;
|
||||
} else {
|
||||
apiKey = preferences.geminiApiKey;
|
||||
}
|
||||
|
||||
onGenerate({
|
||||
topic: generateMode === 'topic' ? topic.trim() : undefined,
|
||||
|
|
@ -230,7 +239,9 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
useOcr: generateMode === 'document' && showOcrOption ? useOcr : undefined,
|
||||
aiProvider,
|
||||
apiKey,
|
||||
geminiModel: aiProvider === 'gemini' ? preferences.geminiModel : undefined,
|
||||
openRouterModel: aiProvider === 'openrouter' ? preferences.openRouterModel : undefined,
|
||||
openAIModel: aiProvider === 'openai' ? preferences.openAIModel : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -518,6 +529,23 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
|
|||
</>
|
||||
)}
|
||||
|
||||
{!canUseAI && (
|
||||
<button
|
||||
onClick={() => setSearchParams(buildCleanParams({ modal: 'account' }))}
|
||||
className="w-full p-4 bg-theme-primary/5 border-2 border-theme-primary/20 rounded-2xl text-left hover:border-theme-primary hover:bg-theme-primary/10 hover:shadow-lg hover:scale-[1.02] transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-theme-primary/15 rounded-xl group-hover:bg-theme-primary/25 transition-colors">
|
||||
<Sparkles size={20} className="text-theme-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-gray-800">AI Quiz Generation Available</p>
|
||||
<p className="text-sm text-gray-500">Configure your API key in settings to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onCreateManual}
|
||||
className="w-full bg-white border-2 border-theme-primary text-theme-primary py-3 rounded-2xl text-lg font-black hover:bg-theme-hover shadow-[0_4px_0_var(--theme-primary)] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import chroma from 'chroma-js';
|
||||
import {
|
||||
Cat, Dog, Bird, Fish, Rabbit, Turtle,
|
||||
Ghost, Skull, Heart, Star, Moon, Sun,
|
||||
|
|
@ -15,20 +16,13 @@ const ICONS = [
|
|||
Rocket, Plane, Car, Bike, Train, Ship
|
||||
];
|
||||
|
||||
const GRADIENT_PAIRS = [
|
||||
['#f472b6', '#c026d3'],
|
||||
['#fb923c', '#ea580c'],
|
||||
['#facc15', '#ca8a04'],
|
||||
['#4ade80', '#16a34a'],
|
||||
['#2dd4bf', '#0d9488'],
|
||||
['#38bdf8', '#0284c7'],
|
||||
['#818cf8', '#6366f1'],
|
||||
['#f87171', '#dc2626'],
|
||||
['#a78bfa', '#7c3aed'],
|
||||
['#fb7185', '#e11d48'],
|
||||
['#34d399', '#059669'],
|
||||
['#60a5fa', '#2563eb'],
|
||||
];
|
||||
export const getAvatarColors = (seed: number): [string, string] => {
|
||||
const hue = Math.floor(seed * 360);
|
||||
const primaryColor = chroma.hsl(hue, 0.85, 0.55);
|
||||
const secondaryColor = chroma.hsl((hue + 35) % 360, 0.8, 0.45);
|
||||
|
||||
return [primaryColor.hex(), secondaryColor.hex()];
|
||||
};
|
||||
|
||||
interface PlayerAvatarProps {
|
||||
seed: number;
|
||||
|
|
@ -38,10 +32,9 @@ interface PlayerAvatarProps {
|
|||
|
||||
export const PlayerAvatar: React.FC<PlayerAvatarProps> = ({ seed, size = 24, className = '' }) => {
|
||||
const iconIndex = Math.floor(seed * 1000) % ICONS.length;
|
||||
const gradientIndex = Math.floor(seed * 10000) % GRADIENT_PAIRS.length;
|
||||
|
||||
const Icon = ICONS[iconIndex];
|
||||
const [colorFrom, colorTo] = GRADIENT_PAIRS[gradientIndex];
|
||||
const [colorFrom, colorTo] = getAvatarColors(seed);
|
||||
const gradientId = `avatar-gradient-${seed.toString().replace('.', '-')}`;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Player, PointsBreakdown } from '../types';
|
||||
import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion';
|
||||
import { Loader2, Flame, Rocket, Zap, X } from 'lucide-react';
|
||||
import { PlayerAvatar } from './PlayerAvatar';
|
||||
import { motion, AnimatePresence, useSpring, useTransform, LayoutGroup } from 'framer-motion';
|
||||
import { Loader2, Flame, Rocket, Zap, X, Crown, Medal, Trophy } from 'lucide-react';
|
||||
import { PlayerAvatar, getAvatarColors } from './PlayerAvatar';
|
||||
|
||||
const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 600 }) => {
|
||||
const spring = useSpring(0, { duration });
|
||||
|
|
@ -53,126 +53,153 @@ const PenaltyBadge: React.FC<{ points: number; delay: number }> = ({ points, del
|
|||
|
||||
interface PlayerRowProps {
|
||||
player: Player & { displayName: string };
|
||||
index: number;
|
||||
maxScore: number;
|
||||
rank: number;
|
||||
currentScore: number;
|
||||
phase: number;
|
||||
baseDelay: number;
|
||||
}
|
||||
|
||||
const PlayerRow: React.FC<PlayerRowProps> = ({ player, index, maxScore }) => {
|
||||
const [phase, setPhase] = useState(0);
|
||||
const PlayerRow: React.FC<PlayerRowProps> = ({ player, maxScore, rank, currentScore, phase, baseDelay }) => {
|
||||
const breakdown = player.pointsBreakdown;
|
||||
const baseDelay = index * 0.3;
|
||||
|
||||
useEffect(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
timers.push(setTimeout(() => setPhase(1), (baseDelay + 0.2) * 1000));
|
||||
if (breakdown) {
|
||||
if (breakdown.streakBonus > 0) timers.push(setTimeout(() => setPhase(2), (baseDelay + 0.8) * 1000));
|
||||
if (breakdown.comebackBonus > 0) timers.push(setTimeout(() => setPhase(3), (baseDelay + 1.2) * 1000));
|
||||
if (breakdown.firstCorrectBonus > 0) timers.push(setTimeout(() => setPhase(4), (baseDelay + 1.6) * 1000));
|
||||
}
|
||||
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [baseDelay, breakdown]);
|
||||
const barWidth = maxScore > 0 ? (currentScore / maxScore) * 100 : 0;
|
||||
|
||||
const getDisplayScore = () => {
|
||||
if (!breakdown) return player.previousScore;
|
||||
|
||||
let score = player.previousScore;
|
||||
if (phase >= 1) score += breakdown.basePoints - breakdown.penalty;
|
||||
if (phase >= 2) score += breakdown.streakBonus;
|
||||
if (phase >= 3) score += breakdown.comebackBonus;
|
||||
if (phase >= 4) score += breakdown.firstCorrectBonus;
|
||||
return Math.max(0, score);
|
||||
};
|
||||
const isFirst = rank === 1;
|
||||
const isSecond = rank === 2;
|
||||
const isThird = rank === 3;
|
||||
const isTop3 = rank <= 3;
|
||||
|
||||
const barWidth = maxScore > 0 ? (getDisplayScore() / maxScore) * 100 : 0;
|
||||
let rankStyles = "bg-white border-gray-100";
|
||||
let rankBadgeStyles = "bg-gray-100 text-gray-500";
|
||||
|
||||
if (isFirst) {
|
||||
rankStyles = "bg-gradient-to-r from-yellow-50 to-amber-100 border-amber-300 shadow-lg scale-[1.02] z-10";
|
||||
rankBadgeStyles = "bg-gradient-to-br from-yellow-400 to-amber-600 text-white shadow-sm";
|
||||
} else if (isSecond) {
|
||||
rankStyles = "bg-gradient-to-r from-gray-50 to-slate-100 border-slate-300 shadow-md z-0";
|
||||
rankBadgeStyles = "bg-gradient-to-br from-slate-300 to-slate-500 text-white shadow-sm";
|
||||
} else if (isThird) {
|
||||
rankStyles = "bg-gradient-to-r from-orange-50 to-orange-100 border-orange-200 shadow-md z-0";
|
||||
rankBadgeStyles = "bg-gradient-to-br from-orange-300 to-orange-500 text-white shadow-sm";
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: baseDelay, duration: 0.4 }}
|
||||
className="flex flex-col gap-2 py-3"
|
||||
layout
|
||||
layoutId={player.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
layout: { type: "spring", stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.3 }
|
||||
}}
|
||||
className={`flex items-center gap-3 md:gap-4 p-3 md:p-4 rounded-xl md:rounded-2xl border-2 ${rankStyles}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 w-full">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<PlayerAvatar seed={player.avatarSeed} size={32} />
|
||||
<span className="font-black text-lg font-display truncate">{player.displayName}</span>
|
||||
<div className={`flex items-center justify-center w-8 h-8 md:w-10 md:h-10 rounded-full font-black text-lg md:text-xl shrink-0 ${rankBadgeStyles}`}>
|
||||
{rank}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3 w-full">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative">
|
||||
<PlayerAvatar seed={player.avatarSeed} size={isTop3 ? 40 : 32} />
|
||||
{isFirst && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -45 }}
|
||||
animate={{ scale: 1, rotate: -15 }}
|
||||
transition={{ delay: 0.5, type: 'spring' }}
|
||||
className="absolute -top-3 -left-2 drop-shadow-md"
|
||||
>
|
||||
<Crown size={24} className="fill-yellow-400 text-yellow-600" />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`font-black font-display truncate ${isTop3 ? 'text-xl' : 'text-lg'}`}>
|
||||
{player.displayName}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`font-black font-display ${isTop3 ? 'text-3xl text-theme-primary' : 'text-2xl text-gray-700'}`}>
|
||||
<AnimatedNumber value={currentScore} />
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-black text-2xl font-display">
|
||||
<AnimatedNumber value={getDisplayScore()} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-10 md:h-12 bg-gray-100 rounded-full overflow-hidden relative">
|
||||
<motion.div
|
||||
className="h-full rounded-full"
|
||||
style={{ backgroundColor: player.color }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.max(barWidth, 2)}%` }}
|
||||
transition={{ duration: 0.6, delay: phase === 0 ? baseDelay + 0.1 : 0 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full h-8 md:h-10 bg-black/5 rounded-full overflow-hidden relative">
|
||||
<motion.div
|
||||
className="h-full rounded-full"
|
||||
style={{ background: `linear-gradient(90deg, ${getAvatarColors(player.avatarSeed)[0]}, ${getAvatarColors(player.avatarSeed)[1]})` }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.max(barWidth, 2)}%` }}
|
||||
transition={{ duration: 0.6, delay: phase === 0 ? baseDelay + 0.1 : 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 w-full justify-start">
|
||||
<AnimatePresence>
|
||||
{breakdown === null && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: baseDelay + 0.3 }}
|
||||
className="text-gray-400 font-medium text-sm"
|
||||
>
|
||||
No answer
|
||||
</motion.span>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 w-full justify-start h-6">
|
||||
<AnimatePresence mode='popLayout'>
|
||||
{breakdown === null && (
|
||||
<motion.span
|
||||
key="no-answer"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ delay: baseDelay + 0.3 }}
|
||||
className="text-gray-400 font-medium text-sm"
|
||||
>
|
||||
No answer
|
||||
</motion.span>
|
||||
)}
|
||||
|
||||
{breakdown && breakdown.penalty > 0 && phase >= 1 && (
|
||||
<PenaltyBadge points={breakdown.penalty} delay={0} />
|
||||
)}
|
||||
{breakdown && breakdown.penalty > 0 && phase >= 1 && (
|
||||
<PenaltyBadge key="penalty" points={breakdown.penalty} delay={0} />
|
||||
)}
|
||||
|
||||
{breakdown && breakdown.basePoints > 0 && phase >= 1 && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-full bg-green-500 text-white font-bold text-sm"
|
||||
>
|
||||
<span>+{breakdown.basePoints}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{breakdown && breakdown.basePoints > 0 && phase >= 1 && (
|
||||
<motion.div
|
||||
key="base-points"
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-500 text-white font-bold text-xs md:text-sm"
|
||||
>
|
||||
<span>+{breakdown.basePoints}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{breakdown && breakdown.streakBonus > 0 && phase >= 2 && (
|
||||
<BonusBadge
|
||||
points={breakdown.streakBonus}
|
||||
label="Streak"
|
||||
icon={<Flame size={14} />}
|
||||
color="bg-amber-500"
|
||||
delay={0}
|
||||
/>
|
||||
)}
|
||||
{breakdown && breakdown.streakBonus > 0 && phase >= 2 && (
|
||||
<BonusBadge
|
||||
key="streak"
|
||||
points={breakdown.streakBonus}
|
||||
label="Streak"
|
||||
icon={<Flame size={12} />}
|
||||
color="bg-amber-500"
|
||||
delay={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{breakdown && breakdown.comebackBonus > 0 && phase >= 3 && (
|
||||
<BonusBadge
|
||||
points={breakdown.comebackBonus}
|
||||
label="Comeback"
|
||||
icon={<Rocket size={14} />}
|
||||
color="bg-blue-500"
|
||||
delay={0}
|
||||
/>
|
||||
)}
|
||||
{breakdown && breakdown.comebackBonus > 0 && phase >= 3 && (
|
||||
<BonusBadge
|
||||
key="comeback"
|
||||
points={breakdown.comebackBonus}
|
||||
label="Comeback"
|
||||
icon={<Rocket size={12} />}
|
||||
color="bg-blue-500"
|
||||
delay={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && (
|
||||
<BonusBadge
|
||||
points={breakdown.firstCorrectBonus}
|
||||
label="First!"
|
||||
icon={<Zap size={14} />}
|
||||
color="bg-yellow-500"
|
||||
delay={0}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{breakdown && breakdown.firstCorrectBonus > 0 && phase >= 4 && (
|
||||
<BonusBadge
|
||||
key="first-correct"
|
||||
points={breakdown.firstCorrectBonus}
|
||||
label="First!"
|
||||
icon={<Zap size={12} />}
|
||||
color="bg-yellow-500"
|
||||
delay={0}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
|
@ -185,38 +212,144 @@ interface ScoreboardProps {
|
|||
currentPlayerId: string | null;
|
||||
}
|
||||
|
||||
interface AnimatedPlayerState extends Player {
|
||||
displayName: string;
|
||||
currentScore: number;
|
||||
phase: number;
|
||||
initialIndex: number;
|
||||
}
|
||||
|
||||
export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost, currentPlayerId }) => {
|
||||
const playersWithDisplayName = players.map(p => ({
|
||||
...p,
|
||||
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name
|
||||
}));
|
||||
const sortedPlayers = [...playersWithDisplayName].sort((a, b) => b.score - a.score);
|
||||
const maxScore = Math.max(...sortedPlayers.map(p => Math.max(p.score, p.previousScore)), 1);
|
||||
// Initialize players sorted by previousScore to start
|
||||
const [animatedPlayers, setAnimatedPlayers] = useState<AnimatedPlayerState[]>(() => {
|
||||
const playersWithMeta = players.map(p => ({
|
||||
...p,
|
||||
displayName: p.id === currentPlayerId ? `${p.name} (You)` : p.name,
|
||||
currentScore: p.previousScore,
|
||||
phase: 0,
|
||||
}));
|
||||
|
||||
// Sort by previous score initially (descending)
|
||||
// Add a secondary sort by ID to ensure stable sorting
|
||||
return playersWithMeta
|
||||
.sort((a, b) => (b.previousScore - a.previousScore) || a.id.localeCompare(b.id))
|
||||
.map((p, index) => ({
|
||||
...p,
|
||||
initialIndex: index // Store the initial rank for staggered animation timing
|
||||
}));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timers: NodeJS.Timeout[] = [];
|
||||
|
||||
// We use the initial state to set up the timers
|
||||
// We can't use animatedPlayers in the dependency array or it will loop
|
||||
// But we need a reference to the initial setup to schedule things.
|
||||
// The state initializer runs once, so we need to reconstruct that list or trust the state is fresh on mount.
|
||||
// However, inside useEffect, animatedPlayers might change if we include it in deps.
|
||||
// We want to schedule based on the INITIAL state.
|
||||
|
||||
// Let's grab the initial list again to be safe and consistent with the initializer logic
|
||||
const initialPlayers = players.map(p => ({
|
||||
...p,
|
||||
previousScore: p.previousScore,
|
||||
pointsBreakdown: p.pointsBreakdown
|
||||
})).sort((a, b) => (b.previousScore - a.previousScore) || a.id.localeCompare(b.id));
|
||||
|
||||
initialPlayers.forEach((player, initialIndex) => {
|
||||
const breakdown = player.pointsBreakdown;
|
||||
const baseDelay = initialIndex * 0.1;
|
||||
|
||||
// Helper to update state
|
||||
const updatePlayerState = (phase: number, scoreToAdd: number) => {
|
||||
setAnimatedPlayers(prev => {
|
||||
const updated = prev.map(p => {
|
||||
if (p.id !== player.id) return p;
|
||||
return {
|
||||
...p,
|
||||
phase,
|
||||
currentScore: p.currentScore + scoreToAdd
|
||||
};
|
||||
});
|
||||
// Re-sort on every update
|
||||
return updated.sort((a, b) => (b.currentScore - a.currentScore) || a.id.localeCompare(b.id));
|
||||
});
|
||||
};
|
||||
|
||||
if (!breakdown) return;
|
||||
|
||||
// Phase 1: Base Points + Penalty
|
||||
// (baseDelay + 0.2)s
|
||||
timers.push(setTimeout(() => {
|
||||
const points = breakdown.basePoints - breakdown.penalty;
|
||||
updatePlayerState(1, points);
|
||||
}, (baseDelay + 0.2) * 1000));
|
||||
|
||||
// Phase 2: Streak
|
||||
// (baseDelay + 0.8)s
|
||||
if (breakdown.streakBonus > 0) {
|
||||
timers.push(setTimeout(() => {
|
||||
updatePlayerState(2, breakdown.streakBonus);
|
||||
}, (baseDelay + 0.8) * 1000));
|
||||
}
|
||||
|
||||
// Phase 3: Comeback
|
||||
// (baseDelay + 1.2)s
|
||||
if (breakdown.comebackBonus > 0) {
|
||||
timers.push(setTimeout(() => {
|
||||
updatePlayerState(3, breakdown.comebackBonus);
|
||||
}, (baseDelay + 1.2) * 1000));
|
||||
}
|
||||
|
||||
// Phase 4: First Correct
|
||||
// (baseDelay + 1.6)s
|
||||
if (breakdown.firstCorrectBonus > 0) {
|
||||
timers.push(setTimeout(() => {
|
||||
updatePlayerState(4, breakdown.firstCorrectBonus);
|
||||
}, (baseDelay + 1.6) * 1000));
|
||||
}
|
||||
});
|
||||
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, []); // Run once on mount
|
||||
|
||||
// Calculate max score based on FINAL scores (so the bar scale is consistent/correct relative to the winner)
|
||||
const maxScore = Math.max(...players.map(p => p.score), 1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen p-4 md:p-8 overflow-hidden">
|
||||
<div className="flex flex-col h-screen p-4 md:p-8 overflow-hidden bg-theme-bg">
|
||||
<header className="text-center mb-4 md:mb-8 shrink-0">
|
||||
<h1 className="text-3xl md:text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 min-h-0 bg-white rounded-2xl md:rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-4 md:p-12 text-gray-900 max-w-5xl w-full mx-auto relative z-10 border-4 md:border-8 border-white/50 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{sortedPlayers.map((player, index) => (
|
||||
<PlayerRow key={player.id} player={player} index={index} maxScore={maxScore} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 bg-white/95 backdrop-blur-sm rounded-2xl md:rounded-[3rem] shadow-[0_20px_50px_rgba(0,0,0,0.3)] p-4 md:p-8 text-gray-900 max-w-4xl w-full mx-auto relative z-10 border-4 md:border-8 border-white/50 overflow-y-auto custom-scrollbar">
|
||||
<LayoutGroup>
|
||||
<div className="space-y-3">
|
||||
{animatedPlayers.map((player, index) => (
|
||||
<PlayerRow
|
||||
key={player.id}
|
||||
player={player}
|
||||
maxScore={maxScore}
|
||||
rank={index + 1}
|
||||
currentScore={player.currentScore}
|
||||
phase={player.phase}
|
||||
baseDelay={player.initialIndex * 0.1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-5xl w-full mx-auto shrink-0">
|
||||
<div className="mt-4 md:mt-8 flex justify-center md:justify-end max-w-4xl w-full mx-auto shrink-0 z-20">
|
||||
{isHost ? (
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="bg-white text-theme-primary px-8 md:px-12 py-3 md:py-4 rounded-xl md:rounded-2xl text-xl md:text-2xl font-black shadow-[0_8px_0_rgba(0,0,0,0.2)] hover:scale-105 active:shadow-none active:translate-y-[8px] transition-all"
|
||||
className="bg-white text-theme-primary px-8 md:px-12 py-3 md:py-4 rounded-xl md:rounded-2xl text-xl md:text-2xl font-black shadow-[0_8px_0_rgba(0,0,0,0.2)] hover:scale-105 active:shadow-none active:translate-y-[8px] transition-all flex items-center gap-2"
|
||||
>
|
||||
Next
|
||||
Next <Trophy size={24} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 md:gap-3 bg-white/10 px-4 md:px-8 py-3 md:py-4 rounded-xl md:rounded-2xl backdrop-blur-md border-2 border-white/20">
|
||||
<div className="flex items-center gap-2 md:gap-3 bg-white/10 px-4 md:px-8 py-3 md:py-4 rounded-xl md:rounded-2xl backdrop-blur-md border-2 border-white/20 shadow-lg text-white">
|
||||
<Loader2 className="animate-spin w-6 h-6 md:w-8 md:h-8" />
|
||||
<span className="text-base md:text-xl font-bold">Waiting for host...</span>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue