mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-01-31 04:22:42 +00:00
✨ feat: Initial implementation
This commit is contained in:
64
src/App.css
Normal file
64
src/App.css
Normal file
@@ -0,0 +1,64 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #f6f6f6;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container p {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #f6f6f6;
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
}
|
||||
129
src/App.tsx
Normal file
129
src/App.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { ThemeProvider } from './components/theme-provider';
|
||||
import { ToasterComponent } from './components/toaster';
|
||||
import { MainLayout } from './layouts/MainLayout';
|
||||
import { Login } from './pages/Login';
|
||||
import { Verify2FA } from './pages/Verify2FA';
|
||||
import { Home } from './pages/Home';
|
||||
import { Profile } from './pages/Profile';
|
||||
import { Worlds } from './pages/Worlds';
|
||||
import { Avatars } from './pages/Avatars';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import './App.css';
|
||||
|
||||
function AppRoute({ children }: { children: React.ReactNode }) {
|
||||
const { loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <MainLayout>{children}</MainLayout>;
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vrc-circle-theme">
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ToasterComponent />
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<Login />
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/verify-2fa"
|
||||
element={<Verify2FA />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<AppRoute>
|
||||
<Home />
|
||||
</AppRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<AppRoute>
|
||||
<Profile />
|
||||
</AppRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile/:userId"
|
||||
element={
|
||||
<AppRoute>
|
||||
<Profile />
|
||||
</AppRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/worlds"
|
||||
element={
|
||||
<AppRoute>
|
||||
<Worlds />
|
||||
</AppRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/avatars"
|
||||
element={
|
||||
<AppRoute>
|
||||
<Avatars />
|
||||
</AppRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<AppRoute>
|
||||
<Settings />
|
||||
</AppRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
481
src/components/AccountMenu.tsx
Normal file
481
src/components/AccountMenu.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { AccountService } from "@/services/account";
|
||||
import { WebSocketService } from "@/services/websocket";
|
||||
import { VRChatService } from "@/services/vrchat";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
LogOut,
|
||||
Users,
|
||||
Settings,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
IdCard,
|
||||
UserCircle,
|
||||
CircleDot,
|
||||
X,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import { UserAvatar } from "@/components/UserAvatar";
|
||||
import type { UserStatus } from "@/types/bindings";
|
||||
import type { StoredAccount } from "@/types/bindings";
|
||||
import { accountsStore } from "@/stores";
|
||||
import { getStatusDotClass } from "@/lib/utils";
|
||||
|
||||
interface AccountMenuProps {
|
||||
showThemeToggle?: boolean;
|
||||
}
|
||||
|
||||
export function AccountMenu({
|
||||
showThemeToggle: _showThemeToggle = false,
|
||||
}: AccountMenuProps) {
|
||||
const { user, logout, clearLocalSession, setUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [accounts, setAccounts] = useState<StoredAccount[]>(
|
||||
accountsStore.getSnapshot() ?? []
|
||||
);
|
||||
const [switchingAccount, setSwitchingAccount] = useState<string | null>(null);
|
||||
const [updatingStatus, setUpdatingStatus] = useState(false);
|
||||
const [customStatusInput, setCustomStatusInput] = useState("");
|
||||
const [accountsExpanded, setAccountsExpanded] = useState(() => {
|
||||
const switchable =
|
||||
accountsStore.getSnapshot()?.filter((acc) => acc.user_id !== user?.id) ??
|
||||
[];
|
||||
return switchable.length <= 3;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = accountsStore.subscribe((value) => {
|
||||
setAccounts(value ?? []);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const switchableAccounts = accounts.filter(
|
||||
(account) => account.user_id !== user?.id
|
||||
);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAccount = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await clearLocalSession();
|
||||
setMenuOpen(false);
|
||||
navigate("/login");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewProfile = () => {
|
||||
setMenuOpen(false);
|
||||
navigate("/profile");
|
||||
};
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
setMenuOpen(false);
|
||||
navigate("/settings");
|
||||
};
|
||||
|
||||
const handleStatusChange = async (
|
||||
status: UserStatus | string,
|
||||
statusDescription: string = ""
|
||||
) => {
|
||||
if (!user || updatingStatus) return;
|
||||
|
||||
setUpdatingStatus(true);
|
||||
try {
|
||||
const updatedUser = await VRChatService.updateStatus(
|
||||
status,
|
||||
statusDescription
|
||||
);
|
||||
setUser(updatedUser);
|
||||
setCustomStatusInput("");
|
||||
} catch (error) {
|
||||
console.error("Failed to update status:", error);
|
||||
toast.error(t("component.accountMenu.statusUpdateFailed"));
|
||||
} finally {
|
||||
setUpdatingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomStatusSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (customStatusInput.trim()) {
|
||||
await handleStatusChange(
|
||||
user?.status || "active",
|
||||
customStatusInput.trim()
|
||||
);
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearStatus = () => {
|
||||
if (!user || updatingStatus) return;
|
||||
handleStatusChange(user.status ?? "active", "");
|
||||
};
|
||||
|
||||
const handleCopyStatus = async () => {
|
||||
if (!user?.statusDescription) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(user.statusDescription);
|
||||
toast.success(t("component.accountMenu.statusCopied"));
|
||||
} catch (error) {
|
||||
console.error("Failed to copy status:", error);
|
||||
toast.error(t("component.accountMenu.statusCopyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickSwitch = async (userId: string) => {
|
||||
if (switchingAccount || userId === user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSwitchingAccount(userId);
|
||||
try {
|
||||
if (user) {
|
||||
await accountsStore.saveFromUser(user);
|
||||
}
|
||||
|
||||
// Stop current WebSocket
|
||||
await WebSocketService.stop();
|
||||
|
||||
// Switch account
|
||||
const switchedUser = await AccountService.switchAccount(userId);
|
||||
setUser(switchedUser);
|
||||
|
||||
// Start WebSocket for new account
|
||||
await WebSocketService.start();
|
||||
await accountsStore.refresh();
|
||||
|
||||
if (location.pathname === "/login") {
|
||||
navigate("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to switch account", error);
|
||||
toast.error(t("component.accountMenu.accountSwitchFailed"));
|
||||
} finally {
|
||||
setSwitchingAccount(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="default"
|
||||
className="rounded-full h-10 px-2 pr-3 gap-2 flex items-center"
|
||||
>
|
||||
{user ? (
|
||||
<UserAvatar
|
||||
user={user}
|
||||
className="h-8 w-8"
|
||||
imageClassName="object-cover"
|
||||
fallbackClassName="text-xs font-medium bg-muted/50"
|
||||
statusClassName={getStatusDotClass(user)}
|
||||
statusSize="45%"
|
||||
statusOffset="-12%"
|
||||
statusContainerClassName="bg-background/90 shadow-sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<UserCircle className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{user && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 flex items-center gap-3 group">
|
||||
<div
|
||||
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer rounded-sm -mx-2 px-2 hover:bg-accent transition-colors"
|
||||
onClick={handleViewProfile}
|
||||
>
|
||||
<UserAvatar
|
||||
user={user}
|
||||
className="h-9 w-9"
|
||||
imageClassName="object-cover"
|
||||
fallbackClassName="text-sm font-medium bg-muted/50"
|
||||
statusClassName={getStatusDotClass(user)}
|
||||
statusSize="45%"
|
||||
statusOffset="-10%"
|
||||
statusContainerClassName="bg-background/90 shadow-sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{user.displayName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
@{user.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 shrink-0 text-red-500 hover:text-red-500"
|
||||
title={t("component.accountMenu.logout")}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<>
|
||||
{user.statusDescription && (
|
||||
<div className="group relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
<span className="truncate flex-1" onClick={handleCopyStatus}>
|
||||
{user.statusDescription}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-2 inline-flex size-5 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearStatus();
|
||||
}}
|
||||
title={t("component.accountMenu.clearStatus")}
|
||||
aria-label={t("component.accountMenu.clearStatus")}
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
disabled={updatingStatus}
|
||||
className="[&>svg:last-child]:mr-2"
|
||||
>
|
||||
<CircleDot className="h-4 w-4" />
|
||||
<span>{t("component.accountMenu.setStatus")}</span>
|
||||
{updatingStatus && (
|
||||
<Loader2 className="h-3 w-3 ml-auto animate-spin" />
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleStatusChange("active", user.statusDescription ?? "")
|
||||
}
|
||||
disabled={updatingStatus}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-transparent ring-2 ring-emerald-500 ring-inset" />
|
||||
<span>{t("common.status.active")}</span>
|
||||
</div>
|
||||
{user.status === "active" && (
|
||||
<CheckCircle2 className="h-4 w-4 ml-auto text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleStatusChange("join me", user.statusDescription ?? "")
|
||||
}
|
||||
disabled={updatingStatus}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-sky-500" />
|
||||
<span>{t("common.status.joinMe")}</span>
|
||||
</div>
|
||||
{user.status === "join me" && (
|
||||
<CheckCircle2 className="h-4 w-4 ml-auto text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleStatusChange("ask me", user.statusDescription ?? "")
|
||||
}
|
||||
disabled={updatingStatus}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span>{t("common.status.askMe")}</span>
|
||||
</div>
|
||||
{user.status === "ask me" && (
|
||||
<CheckCircle2 className="h-4 w-4 ml-auto text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleStatusChange("busy", user.statusDescription ?? "")
|
||||
}
|
||||
disabled={updatingStatus}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-500" />
|
||||
<span>{t("common.status.busy")}</span>
|
||||
</div>
|
||||
{user.status === "busy" && (
|
||||
<CheckCircle2 className="h-4 w-4 ml-auto text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-2">
|
||||
<form
|
||||
onSubmit={handleCustomStatusSubmit}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t(
|
||||
"component.accountMenu.customStatusPlaceholder"
|
||||
)}
|
||||
value={customStatusInput}
|
||||
onChange={(e) => setCustomStatusInput(e.target.value)}
|
||||
disabled={updatingStatus}
|
||||
className="h-8 text-sm"
|
||||
maxLength={32}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!customStatusInput.trim() || updatingStatus}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{t("component.accountMenu.setCustomStatus")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuItem onClick={handleViewProfile}>
|
||||
<IdCard className="h-4 w-4" />
|
||||
<span>{t("component.accountMenu.viewProfile")}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm font-semibold hover:bg-accent rounded-sm"
|
||||
onClick={() => setAccountsExpanded(!accountsExpanded)}
|
||||
>
|
||||
<span className="flex-1">
|
||||
{t("component.accountMenu.switchAccount")}
|
||||
</span>
|
||||
{accountsExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
</div>
|
||||
{accountsExpanded &&
|
||||
switchableAccounts.map((account) => {
|
||||
const isCurrent = account.user_id === user?.id;
|
||||
const isSwitching = switchingAccount === account.user_id;
|
||||
const disabled =
|
||||
isCurrent || (switchingAccount !== null && !isSwitching);
|
||||
const primaryAvatar =
|
||||
account.avatar_url ?? account.avatar_fallback_url ?? undefined;
|
||||
const fallbackAvatar =
|
||||
account.avatar_fallback_url ?? account.avatar_url ?? undefined;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={account.user_id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleQuickSwitch(account.user_id);
|
||||
}}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<UserAvatar
|
||||
user={{
|
||||
displayName: account.display_name,
|
||||
userIcon: primaryAvatar,
|
||||
profilePicOverride: primaryAvatar,
|
||||
profilePicOverrideThumbnail: primaryAvatar,
|
||||
currentAvatarImageUrl: fallbackAvatar,
|
||||
currentAvatarThumbnailImageUrl: fallbackAvatar,
|
||||
}}
|
||||
className="h-8 w-8"
|
||||
fallbackClassName="text-xs font-medium"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col flex-1">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{account.display_name ||
|
||||
t("component.accountMenu.unknownUser")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
@
|
||||
{account.username ||
|
||||
t("component.accountMenu.unknownUsername")}
|
||||
</span>
|
||||
</div>
|
||||
{isCurrent ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
|
||||
) : isSwitching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{accountsExpanded && (
|
||||
<DropdownMenuItem onClick={handleAddAccount} disabled={isLoading}>
|
||||
<div className="h-8 w-8 shrink-0 flex items-center justify-center rounded-full bg-muted">
|
||||
<Users className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("component.accountMenu.addAccount")}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleOpenSettings}>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>{t("component.accountMenu.settings")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
101
src/components/AlertBarContainer.tsx
Normal file
101
src/components/AlertBarContainer.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { AlertBar } from "./ui/alert-bar";
|
||||
import { alertStore } from "@/stores";
|
||||
import type { AlertStoreState } from "@/stores/alert-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AlertBarContainer() {
|
||||
// Force re-render every 30 seconds
|
||||
// TODO: Remove this, no longer needed
|
||||
const [, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(Date.now()), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
const [alertState, setAlertState] = useState<AlertStoreState>(
|
||||
alertStore.getSnapshot()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = alertStore.subscribe((state) => {
|
||||
setAlertState(state);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const { alerts, currentIndex } = alertState;
|
||||
const currentAlert = alerts[currentIndex];
|
||||
|
||||
if (!currentAlert) return null;
|
||||
|
||||
const hasMultipleAlerts = alerts.length > 1;
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (currentAlert.dismissable) {
|
||||
alertStore.removeAlert(currentAlert.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async () => {
|
||||
if (currentAlert.onClick) {
|
||||
try {
|
||||
await currentAlert.onClick();
|
||||
} catch (error) {
|
||||
console.error("Alert onClick error:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex-shrink-0 animate-slide-in-from-top">
|
||||
<AlertBar
|
||||
variant={currentAlert.variant}
|
||||
dismissable={currentAlert.dismissable}
|
||||
onDismiss={handleDismiss}
|
||||
className={cn(
|
||||
"w-full rounded-none border-0 border-b transition-opacity",
|
||||
currentAlert.onClick && "cursor-pointer hover:opacity-90"
|
||||
)}
|
||||
onClick={currentAlert.onClick ? handleClick : undefined}
|
||||
>
|
||||
<div className="flex items-center flex-1 min-w-0 gap-2">
|
||||
{/* Alert Message */}
|
||||
<div className="flex-1 min-w-0">{currentAlert.message}</div>
|
||||
|
||||
{/* Navigation Controls (Only show if there are multiple alerts) */}
|
||||
{hasMultipleAlerts && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-4">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
alertStore.previousAlert();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
aria-label="Previous alert"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<span className="text-xs font-mono px-2">
|
||||
{currentIndex + 1}/{alerts.length}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
alertStore.nextAlert();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
aria-label="Next alert"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/CachedImage.tsx
Normal file
24
src/components/CachedImage.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCachedImage } from '@/hooks/useCachedImage';
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
|
||||
type CachedImageProps = ImgHTMLAttributes<HTMLImageElement> & {
|
||||
src: string | null | undefined;
|
||||
};
|
||||
|
||||
export function CachedImage({ src, className, ...rest }: CachedImageProps) {
|
||||
const cachedSrc = useCachedImage(src);
|
||||
|
||||
if (!src && !cachedSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
{...rest}
|
||||
src={cachedSrc ?? src ?? undefined}
|
||||
className={className ? cn(className) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
194
src/components/DevTools.tsx
Normal file
194
src/components/DevTools.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { StoreStudio } from "@/pages/StoreStudio";
|
||||
import { DatabaseStudio } from "@/pages/DatabaseStudio";
|
||||
import { Logs } from "@/pages/Logs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { developerModeStore } from "@/stores/developer-mode-store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function DevTools() {
|
||||
const { t } = useTranslation();
|
||||
const [isDeveloperMode, setIsDeveloperMode] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [height, setHeight] = useState(400);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [shouldRenderContent, setShouldRenderContent] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState(48);
|
||||
|
||||
// TODO: Refactor this animation duration to a shared constant or something
|
||||
// Keep this in sync with the tailwind duration used in classes below
|
||||
const ANIMATION_DURATION = 300;
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = developerModeStore.subscribe(setIsDeveloperMode);
|
||||
developerModeStore.ensure();
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Handle expanded content rendering with animation delay
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
setShouldRenderContent(true);
|
||||
} else {
|
||||
// Delay unmounting to allow exit animation + height transition to finish
|
||||
const timeout = setTimeout(
|
||||
() => setShouldRenderContent(false),
|
||||
ANIMATION_DURATION + 20
|
||||
);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
// Measure the header height so we can animate between header-only (collapsed)
|
||||
// and the expanded panel height. Use ResizeObserver to update if styles change.
|
||||
useEffect(() => {
|
||||
const el = headerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const update = () => setHeaderHeight(el.offsetHeight || 48);
|
||||
update();
|
||||
|
||||
let ro: ResizeObserver | undefined;
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
} else {
|
||||
// Fallback if ResizeObserver somehow isn't supported
|
||||
window.addEventListener("resize", update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (ro) ro.disconnect();
|
||||
else window.removeEventListener("resize", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const newHeight = window.innerHeight - e.clientY;
|
||||
const minHeight = 100;
|
||||
const maxHeight = window.innerHeight - 100;
|
||||
setHeight(Math.max(minHeight, Math.min(maxHeight, newHeight)));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
// Don't render if developer mode is disabled
|
||||
if (!isDeveloperMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"fixed bottom-0 left-0 right-0 z-50 overflow-hidden",
|
||||
!isResizing && "transition-all duration-300 ease-in-out",
|
||||
isResizing && "select-none"
|
||||
)}
|
||||
style={{
|
||||
// We set explicit height so the drag feels immediate for now
|
||||
height: isResizing ? `${height}px` : undefined,
|
||||
maxHeight: `${isExpanded ? height : headerHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full flex flex-col",
|
||||
isExpanded && "bg-background/95 backdrop-blur-md border-t shadow-2xl"
|
||||
)}
|
||||
>
|
||||
{/* Resize handle (only visible when expanded) */}
|
||||
{isExpanded && (
|
||||
<div
|
||||
className="h-1 cursor-ns-resize hover:bg-primary/20 active:bg-primary/40 transition-colors"
|
||||
onMouseDown={() => setIsResizing(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toggle bar (The pull up tab) */}
|
||||
<div
|
||||
ref={headerRef}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-accent/50 transition-colors flex items-center justify-center",
|
||||
isExpanded
|
||||
? "px-4 py-2 border-b"
|
||||
: "mx-auto w-64 px-4 py-1.5 rounded-t-lg bg-background/80 backdrop-blur-sm border border-b-0 shadow-lg"
|
||||
)}
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 transition-transform duration-300" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4 transition-transform duration-300" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{t("layout.sidebar.developerTools")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{shouldRenderContent && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex flex-col overflow-hidden",
|
||||
isExpanded ? "animate-fade-in" : "animate-fade-out"
|
||||
)}
|
||||
>
|
||||
<Tabs
|
||||
defaultValue="logger"
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-4">
|
||||
<TabsTrigger value="logger">
|
||||
{t("layout.developerTools.tabs.logger")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="database-studio">
|
||||
{t("layout.developerTools.tabs.databaseStudio")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="store-studio">
|
||||
{t("layout.developerTools.tabs.storeStudio")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<TabsContent value="logger" className="m-0 h-full">
|
||||
<Logs />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="database-studio" className="m-0 h-full">
|
||||
<DatabaseStudio />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="store-studio" className="m-0 h-full">
|
||||
<StoreStudio heading={false} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
433
src/components/FriendsSidebar.tsx
Normal file
433
src/components/FriendsSidebar.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn, isUserOffline, getStatusDotClass } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Users, ChevronLeft, User, Loader2 } from "lucide-react";
|
||||
import { UserAvatar } from "@/components/UserAvatar";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { userStore } from "@/stores";
|
||||
import type { LimitedUserFriend, UserStatus } from "@/types/bindings";
|
||||
|
||||
const POLL_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
interface FriendsSidebarProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function FriendsSidebar({ isOpen, onToggle }: FriendsSidebarProps) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [friends, setFriends] = useState<LimitedUserFriend[] | null>(
|
||||
userStore.getFriendsSnapshot()
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<"loadFailed" | null>(null);
|
||||
|
||||
const describeLocation = useCallback(
|
||||
(location: string): string => {
|
||||
const normalized = location.toLowerCase();
|
||||
|
||||
if (!location || normalized === "offline") {
|
||||
return t("layout.friendsSidebar.location.offline");
|
||||
}
|
||||
|
||||
if (normalized === "private") {
|
||||
return t("layout.friendsSidebar.location.private");
|
||||
}
|
||||
|
||||
if (normalized.startsWith("traveling")) {
|
||||
return t("layout.friendsSidebar.location.traveling");
|
||||
}
|
||||
|
||||
if (normalized.startsWith("group:")) {
|
||||
return t("layout.friendsSidebar.location.group");
|
||||
}
|
||||
|
||||
if (normalized.includes(":")) {
|
||||
return t("layout.friendsSidebar.location.instance");
|
||||
}
|
||||
|
||||
return location;
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const getStatusText = useCallback(
|
||||
(status?: UserStatus | null) => {
|
||||
if (!status) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case "active":
|
||||
return t("common.status.active");
|
||||
case "join me":
|
||||
return t("common.status.joinMe");
|
||||
case "ask me":
|
||||
return t("common.status.askMe");
|
||||
case "busy":
|
||||
return t("common.status.busy");
|
||||
}
|
||||
|
||||
return status;
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const handleUpdate = (value: LimitedUserFriend[] | null) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setFriends(value);
|
||||
setLoading(false);
|
||||
if (value) {
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = userStore.subscribeFriends(handleUpdate);
|
||||
|
||||
if (!user) {
|
||||
setFriends(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
// Only show loading if we don't already have data
|
||||
const snapshot = userStore.getFriendsSnapshot();
|
||||
if (!snapshot) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
userStore.ensureFriends().catch((err) => {
|
||||
console.error("Failed to load friends:", err);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setError("loadFailed");
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
// Periodic refresh from backend (backend handles WebSocket updates to UserStore)
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const refreshFriends = async (showLoading = true) => {
|
||||
// Don't show loading spinner if we already have data
|
||||
const hasData = userStore.getFriendsSnapshot() !== null;
|
||||
const shouldShowLoading = showLoading && !hasData;
|
||||
|
||||
if (!disposed && shouldShowLoading) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
} else if (!disposed) {
|
||||
setError(null);
|
||||
}
|
||||
try {
|
||||
await userStore.refreshFriends();
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh friends:", err);
|
||||
if (!disposed) {
|
||||
setError("loadFailed");
|
||||
if (shouldShowLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!disposed && shouldShowLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load
|
||||
void refreshFriends(true);
|
||||
|
||||
// Poll periodically to sync with backend UserStore
|
||||
const poller = setInterval(() => {
|
||||
void refreshFriends(false);
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearInterval(poller);
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const getFriendSecondaryText = (friend: LimitedUserFriend) => {
|
||||
const location = friend.location?.trim();
|
||||
if (location && location.toLowerCase() !== "offline") {
|
||||
return describeLocation(location);
|
||||
}
|
||||
if (friend.platform?.toLowerCase() === "web") {
|
||||
return t("layout.friendsSidebar.status.website");
|
||||
}
|
||||
if (friend.statusDescription) {
|
||||
return friend.statusDescription;
|
||||
}
|
||||
return getStatusText(friend.status);
|
||||
};
|
||||
|
||||
const friendsList = friends ?? [];
|
||||
|
||||
const { sections, onlineCount, compactFriends } = useMemo(() => {
|
||||
const inWorld: LimitedUserFriend[] = [];
|
||||
const active: LimitedUserFriend[] = [];
|
||||
const offline: LimitedUserFriend[] = [];
|
||||
|
||||
const isInWorld = (friend: LimitedUserFriend) => {
|
||||
const location = friend.location?.toLowerCase() ?? "";
|
||||
if (!location || location === "offline") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const friend of friendsList) {
|
||||
if (isInWorld(friend)) {
|
||||
inWorld.push(friend);
|
||||
} else if (!isUserOffline(friend)) {
|
||||
active.push(friend);
|
||||
} else {
|
||||
offline.push(friend);
|
||||
}
|
||||
}
|
||||
|
||||
const sectionsData = [
|
||||
{
|
||||
key: "in-world",
|
||||
title: t("layout.friendsSidebar.sections.inWorld.title"),
|
||||
friends: inWorld,
|
||||
emptyMessage: t("layout.friendsSidebar.sections.inWorld.empty"),
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
title: t("layout.friendsSidebar.sections.active.title"),
|
||||
friends: active,
|
||||
emptyMessage: t("layout.friendsSidebar.sections.active.empty"),
|
||||
},
|
||||
{
|
||||
key: "offline",
|
||||
title: t("layout.friendsSidebar.sections.offline.title"),
|
||||
friends: offline,
|
||||
emptyMessage: t("layout.friendsSidebar.sections.offline.empty"),
|
||||
},
|
||||
];
|
||||
|
||||
const online = inWorld.length + active.length;
|
||||
// For the collapsed view we want to show all friends (in-world, active, then offline)
|
||||
// so the compact list should include offline friends as well.
|
||||
const compactList = [...inWorld, ...active, ...offline];
|
||||
|
||||
return {
|
||||
sections: sectionsData,
|
||||
onlineCount: online,
|
||||
compactFriends: compactList,
|
||||
};
|
||||
}, [friendsList, t]);
|
||||
|
||||
const renderFriendRow = (friend: LimitedUserFriend) => (
|
||||
<button
|
||||
key={friend.id}
|
||||
type="button"
|
||||
onClick={() => navigate(`/profile/${friend.id}`)}
|
||||
className="w-full rounded-lg border border-border/70 bg-card/60 px-2 py-1.5 text-left transition-all duration-200 hover:border-border hover:bg-card/80 hover:scale-[1.02] active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<UserAvatar
|
||||
user={{
|
||||
displayName: friend.displayName,
|
||||
userIcon: friend.userIcon,
|
||||
profilePicOverride: friend.profilePicOverride,
|
||||
profilePicOverrideThumbnail: friend.profilePicOverrideThumbnail,
|
||||
currentAvatarImageUrl: friend.currentAvatarImageUrl,
|
||||
currentAvatarThumbnailImageUrl:
|
||||
friend.currentAvatarThumbnailImageUrl,
|
||||
}}
|
||||
className="h-8 w-8"
|
||||
imageClassName="object-cover"
|
||||
fallbackClassName="text-xs font-medium bg-muted/50"
|
||||
statusClassName={getStatusDotClass(friend)}
|
||||
statusSize="45%"
|
||||
statusOffset="-10%"
|
||||
statusContainerClassName="bg-background/90 shadow-sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-tight truncate">
|
||||
{friend.displayName}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground truncate">
|
||||
{getFriendSecondaryText(friend)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
// Show all compact friends when collapsed (no slicing).
|
||||
const compactFriendsToShow = compactFriends;
|
||||
// Only show sections that actually contain friends in the expanded view
|
||||
const visibleSections = sections.filter((s) => s.friends.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-card border-l border-border transition-all duration-300 ease-in-out flex flex-col flex-shrink-0 overflow-hidden",
|
||||
isOpen ? "w-64" : "w-16"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-16 border-b border-border flex items-center justify-between px-4">
|
||||
{isOpen && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
<span className="text-sm font-semibold">
|
||||
{t("layout.friendsSidebar.title")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
className="h-8 w-8"
|
||||
title={
|
||||
isOpen
|
||||
? t("layout.friendsSidebar.collapse")
|
||||
: t("layout.friendsSidebar.expand")
|
||||
}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<User className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Friends List (uses custom ScrollArea to avoid native scrollbar) */}
|
||||
<ScrollArea className="flex-1 p-3">
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-xs text-destructive text-center py-8">
|
||||
{t(`layout.friendsSidebar.errors.${error}`)}
|
||||
</p>
|
||||
) : isOpen ? (
|
||||
visibleSections.length > 0 ? (
|
||||
visibleSections.map((section, index) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className={cn(
|
||||
"space-y-1.5",
|
||||
index > 0 && "pt-4 border-t border-border/60"
|
||||
)}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{section.title}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{section.friends.map((friend) => renderFriendRow(friend))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-8">
|
||||
{t("layout.friendsSidebar.empty.collapsed")}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
// Collapsed view - show avatars only with separators between sections
|
||||
<div className="space-y-1">
|
||||
{compactFriendsToShow.length > 0 ? (
|
||||
<>
|
||||
{sections.map((section, sectionIndex) => {
|
||||
if (section.friends.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={section.key}>
|
||||
{sectionIndex > 0 && (
|
||||
<div className="py-1">
|
||||
<div className="h-px bg-border/60" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{section.friends.map((friend) => (
|
||||
<button
|
||||
key={friend.id}
|
||||
type="button"
|
||||
onClick={() => navigate(`/profile/${friend.id}`)}
|
||||
title={friend.displayName}
|
||||
className="mx-auto block rounded-full transition-transform duration-200 hover:scale-110 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
||||
>
|
||||
<UserAvatar
|
||||
user={{
|
||||
displayName: friend.displayName,
|
||||
userIcon: friend.userIcon,
|
||||
profilePicOverride: friend.profilePicOverride,
|
||||
profilePicOverrideThumbnail:
|
||||
friend.profilePicOverrideThumbnail,
|
||||
currentAvatarImageUrl:
|
||||
friend.currentAvatarImageUrl,
|
||||
currentAvatarThumbnailImageUrl:
|
||||
friend.currentAvatarThumbnailImageUrl,
|
||||
}}
|
||||
className="h-8 w-8 mx-auto"
|
||||
imageClassName="object-cover"
|
||||
fallbackClassName="text-xs font-medium bg-muted/50"
|
||||
statusClassName={getStatusDotClass(friend)}
|
||||
statusSize="45%"
|
||||
statusOffset="-10%"
|
||||
statusContainerClassName="bg-background/90 shadow-sm"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-6">
|
||||
{t("layout.friendsSidebar.empty.collapsed")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer - Online count */}
|
||||
{isOpen && !loading && (
|
||||
<div className="border-t border-border p-3 text-center text-xs text-muted-foreground">
|
||||
{t("layout.friendsSidebar.onlineCount", { count: onlineCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/LoginRequired.tsx
Normal file
38
src/components/LoginRequired.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Lock, LogIn } from "lucide-react";
|
||||
|
||||
export function LoginRequired() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card className="border-dashed">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-muted flex items-center justify-center">
|
||||
<Lock className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">
|
||||
{t("component.loginRequired.title")}
|
||||
</CardTitle>
|
||||
{/* <CardDescription>
|
||||
</CardDescription> */}
|
||||
</CardHeader>
|
||||
<CardContent className="text-center space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("component.loginRequired.bottomText")}
|
||||
</p>
|
||||
<Button onClick={() => navigate("/login")} size="lg">
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
{t("component.loginRequired.signIn")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/components/Navbar.tsx
Normal file
40
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Menu } from "lucide-react";
|
||||
import { AccountMenu } from "@/components/AccountMenu";
|
||||
|
||||
interface NavbarProps {
|
||||
onMenuToggle: () => void;
|
||||
}
|
||||
|
||||
export function Navbar({ onMenuToggle }: NavbarProps) {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="h-16 bg-card border-b border-border flex items-center justify-between px-6">
|
||||
{/* Left */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onMenuToggle}
|
||||
className="lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Center */}
|
||||
<div className="flex-1 flex justify-center"></div>
|
||||
|
||||
{/* Right */}
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
126
src/components/Sidebar.tsx
Normal file
126
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { LucideIcon, Menu, ChevronLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
mainItems: NavigationItem[];
|
||||
bottomItems: NavigationItem[];
|
||||
isActive: (path: string) => boolean;
|
||||
onNavigate: (path: string) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
isOpen,
|
||||
onToggle,
|
||||
mainItems,
|
||||
bottomItems,
|
||||
isActive,
|
||||
onNavigate,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"h-full bg-card border-r border-border transition-[width] duration-300 ease-in-out flex flex-col flex-shrink-0 overflow-hidden",
|
||||
isOpen ? "w-56" : "w-16"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center h-16 border-b border-border px-4 w-full",
|
||||
isOpen ? "justify-between gap-2" : "justify-center"
|
||||
)}
|
||||
>
|
||||
{isOpen && (
|
||||
<span className="font-bold text-lg whitespace-nowrap overflow-hidden text-ellipsis transition-opacity duration-200 ease-in-out">
|
||||
{t("layout.sidebar.appName")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
className="shrink-0 transition-transform duration-300 ease-in-out"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Navigation Items */}
|
||||
<nav className="flex-1 flex flex-col gap-2 p-3">
|
||||
{mainItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={active ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"transition-colors h-10",
|
||||
!isOpen && "w-10 px-0 flex items-center justify-center",
|
||||
isOpen && "w-full justify-start px-3",
|
||||
active && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
onClick={() => onNavigate(item.path)}
|
||||
title={!isOpen ? item.label : undefined}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{isOpen && (
|
||||
<span className="ml-3 truncate text-sm">{item.label}</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom Navigation Items */}
|
||||
{bottomItems.length > 0 && (
|
||||
<div className="border-t border-border p-3">
|
||||
<nav className="flex flex-col gap-2">
|
||||
{bottomItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={active ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"transition-colors h-10",
|
||||
!isOpen && "w-10 px-0 flex items-center justify-center",
|
||||
isOpen && "w-full justify-start px-3",
|
||||
active && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
onClick={() => onNavigate(item.path)}
|
||||
title={!isOpen ? item.label : undefined}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{isOpen && (
|
||||
<span className="ml-3 truncate text-sm">{item.label}</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
90
src/components/UserAvatar.tsx
Normal file
90
src/components/UserAvatar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getUserAvatarUrl, getUserInitials } from "@/lib/user";
|
||||
import type { User } from "@/types/bindings";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCachedImage } from "@/hooks/useCachedImage";
|
||||
|
||||
type MinimalUser = Pick<
|
||||
User,
|
||||
| "displayName"
|
||||
| "userIcon"
|
||||
| "profilePicOverride"
|
||||
| "profilePicOverrideThumbnail"
|
||||
| "currentAvatarImageUrl"
|
||||
| "currentAvatarThumbnailImageUrl"
|
||||
>;
|
||||
|
||||
interface UserAvatarProps {
|
||||
user: MinimalUser;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
fallbackClassName?: string;
|
||||
fallbackText?: string;
|
||||
statusClassName?: string;
|
||||
avatarClassName?: string;
|
||||
statusSize?: string;
|
||||
statusOffset?: string;
|
||||
statusContainerClassName?: string;
|
||||
}
|
||||
|
||||
export function UserAvatar({
|
||||
user,
|
||||
className,
|
||||
imageClassName,
|
||||
fallbackClassName,
|
||||
fallbackText,
|
||||
statusClassName,
|
||||
avatarClassName,
|
||||
statusSize,
|
||||
statusOffset,
|
||||
statusContainerClassName,
|
||||
}: UserAvatarProps) {
|
||||
const avatarUrl = getUserAvatarUrl(user);
|
||||
const cachedSrc = useCachedImage(avatarUrl);
|
||||
const displaySrc = cachedSrc ?? undefined;
|
||||
const initials = fallbackText ?? getUserInitials(user.displayName);
|
||||
const indicatorSize = statusSize ?? "38%";
|
||||
const indicatorOffset = statusOffset ?? "4%";
|
||||
const indicatorStyle: CSSProperties = {
|
||||
width: indicatorSize,
|
||||
height: indicatorSize,
|
||||
bottom: indicatorOffset,
|
||||
right: indicatorOffset,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative inline-flex", className)}>
|
||||
<Avatar className={cn("h-full w-full", avatarClassName)}>
|
||||
{displaySrc ? (
|
||||
<AvatarImage
|
||||
src={displaySrc}
|
||||
alt={user.displayName ?? "User avatar"}
|
||||
className={cn("object-cover", imageClassName)}
|
||||
/>
|
||||
) : null}
|
||||
<AvatarFallback
|
||||
className={cn("text-sm font-medium uppercase", fallbackClassName)}
|
||||
>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{statusClassName ? (
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute flex items-center justify-center rounded-full bg-background shadow-sm",
|
||||
statusContainerClassName
|
||||
)}
|
||||
style={indicatorStyle}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block h-[68%] w-[68%] rounded-full",
|
||||
statusClassName
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
src/components/store-studio/StoreEditor.tsx
Normal file
134
src/components/store-studio/StoreEditor.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
// TODO: Localize all strings
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { Save, X, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface StoreEditorProps {
|
||||
storeName: string;
|
||||
initialValue: unknown;
|
||||
onSave: (value: unknown) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
scopeLabel?: string;
|
||||
}
|
||||
|
||||
export function StoreEditor({
|
||||
storeName,
|
||||
initialValue,
|
||||
onSave,
|
||||
onCancel,
|
||||
scopeLabel,
|
||||
}: StoreEditorProps) {
|
||||
const [jsonText, setJsonText] = useState(() =>
|
||||
JSON.stringify(initialValue, null, 2)
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
|
||||
await onSave(parsed);
|
||||
toast.success(`${storeName} saved successfully`);
|
||||
|
||||
onCancel();
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
setError(`Invalid JSON: ${err.message}`);
|
||||
toast.error("Invalid JSON");
|
||||
} else {
|
||||
setError(
|
||||
`Failed to save: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`
|
||||
);
|
||||
toast.error("Save failed");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = () => {
|
||||
try {
|
||||
JSON.parse(jsonText);
|
||||
setError(null);
|
||||
toast.success("Valid JSON");
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
setError(`Invalid JSON: ${err.message}`);
|
||||
toast.error("Invalid JSON");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-yellow-400 dark:border-yellow-600">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
{`${storeName} Editor`} {scopeLabel ? `(${scopeLabel})` : ""}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Edit the JSON for this store and save your changes.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
className="font-mono text-xs min-h-[300px] max-h-[60vh]"
|
||||
placeholder={"Paste JSON here..."}
|
||||
/>
|
||||
{error && (
|
||||
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded border border-destructive/30">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} disabled={saving || !!error} size="sm">
|
||||
{saving ? (
|
||||
<>Saving...</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button onClick={handleValidate} variant="outline" size="sm">
|
||||
Validate
|
||||
</Button>
|
||||
<Button onClick={onCancel} variant="ghost" size="sm">
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-xs text-yellow-900 dark:text-yellow-200">
|
||||
<strong>Warning:</strong> Editing store data can break the app if you
|
||||
save invalid JSON. Proceed with caution.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
64
src/components/store-studio/StoreStudioError.tsx
Normal file
64
src/components/store-studio/StoreStudioError.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// TODO: Localize all strings
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface StoreStudioErrorProps {
|
||||
storeName?: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function StoreStudioError({
|
||||
storeName,
|
||||
onRetry,
|
||||
}: StoreStudioErrorProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-6 bg-background">
|
||||
<Card className="max-w-2xl w-full border-destructive">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-8 w-8 text-destructive" />
|
||||
<div>
|
||||
<CardTitle className="text-2xl">Connection error</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Can't attach to main window stores.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{storeName ? (
|
||||
<>
|
||||
Couldn't find <strong>{storeName}</strong> in the main window.
|
||||
</>
|
||||
) : (
|
||||
<>Store Studio couldn't attach to the main window.</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ensure the main app is open and logged in, then retry.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} variant="default">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
src/components/store-studio/StoreViewer.tsx
Normal file
237
src/components/store-studio/StoreViewer.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
// TODO: Localize all strings
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Loader2, Edit, Clock, Database } from "lucide-react";
|
||||
import type {
|
||||
StoreEntry,
|
||||
StoreAction,
|
||||
StoreDebugData,
|
||||
} from "@/types/store-studio";
|
||||
import { StoreEditor } from "./StoreEditor";
|
||||
|
||||
interface StoreViewerProps<T = unknown> {
|
||||
store: StoreEntry<T>;
|
||||
}
|
||||
|
||||
function formatAge(ageMs: number | null): string {
|
||||
if (ageMs === null) {
|
||||
return "never";
|
||||
}
|
||||
const seconds = Math.floor(ageMs / 1000);
|
||||
if (seconds < 1) return "<1s";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
export function StoreViewer<T = unknown>({ store }: StoreViewerProps<T>) {
|
||||
console.log("[StoreViewer] Component rendering for store:", store.label);
|
||||
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [debugInfo, setDebugInfo] = useState<StoreDebugData | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Subscribe to store changes
|
||||
useEffect(() => {
|
||||
console.log(`[StoreViewer] ${store.label} setting up subscription`);
|
||||
|
||||
const unsubscribe = store.subscribe((value) => {
|
||||
console.log(
|
||||
`[StoreViewer] ${store.label} received update via subscription:`,
|
||||
value !== null ? "(data)" : "null"
|
||||
);
|
||||
setData(value);
|
||||
if (store.debugInfo) {
|
||||
setDebugInfo(store.debugInfo());
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log(`[StoreViewer] ${store.label} cleaning up subscription`);
|
||||
unsubscribe();
|
||||
};
|
||||
}, [store]);
|
||||
|
||||
// Refresh debug info every second to update age display
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (store.debugInfo) {
|
||||
setDebugInfo(store.debugInfo());
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [store]);
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async (newValue: unknown) => {
|
||||
if (store.setData) {
|
||||
await store.setData(newValue as T);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (isEditing && store.canEdit && store.setData) {
|
||||
return (
|
||||
<StoreEditor
|
||||
storeName={store.label}
|
||||
initialValue={data}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const showEditButton = store.canEdit && store.setData;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
{store.label}
|
||||
</CardTitle>
|
||||
{store.description && (
|
||||
<CardDescription className="mt-2">
|
||||
{store.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{showEditButton && (
|
||||
<Button onClick={handleEdit} variant="outline" size="sm">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{debugInfo && (
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
||||
{debugInfo.updatedAt !== undefined &&
|
||||
debugInfo.updatedAt !== null && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>
|
||||
Updated:{" "}
|
||||
{debugInfo.updatedAt
|
||||
? new Date(debugInfo.updatedAt).toLocaleString()
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{debugInfo.ageMs !== undefined && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span>Age: {formatAge(debugInfo.ageMs)}</span>
|
||||
</div>
|
||||
)}
|
||||
{debugInfo.stale !== undefined && (
|
||||
<Badge variant={debugInfo.stale ? "destructive" : "secondary"}>
|
||||
{debugInfo.stale ? "Stale" : "Fresh"}
|
||||
</Badge>
|
||||
)}
|
||||
{debugInfo.inflight && (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{store.actions && store.actions.length > 0 && (
|
||||
<CardContent className="border-t pt-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{store.actions.map((action) => (
|
||||
<ActionButton key={action.id} action={action} />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Data</CardTitle>
|
||||
<CardDescription>
|
||||
Real-time view of store content (updates instantly via
|
||||
subscriptions)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[60vh] rounded border bg-muted/40">
|
||||
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{(() => {
|
||||
if (data === null) {
|
||||
return "<null>";
|
||||
}
|
||||
if (data === undefined) {
|
||||
return "<undefined>";
|
||||
}
|
||||
if (Array.isArray(data) && data.length === 0) {
|
||||
return "[] (empty array)";
|
||||
}
|
||||
return JSON.stringify(data, null, 2);
|
||||
})()}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
action: StoreAction;
|
||||
}
|
||||
|
||||
function ActionButton({ action }: ActionButtonProps) {
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
await action.onClick();
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
variant={action.variant || "outline"}
|
||||
size="sm"
|
||||
disabled={action.disabled || pending}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
action.icon
|
||||
)}
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
82
src/components/theme-provider.tsx
Normal file
82
src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vrc-circle-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
|
||||
// Listen for system theme changes
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
root.classList.remove("light", "dark")
|
||||
root.classList.add(e.matches ? "dark" : "light")
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange)
|
||||
return () => mediaQuery.removeEventListener("change", handleChange)
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
|
||||
return context
|
||||
}
|
||||
39
src/components/theme-toggle.tsx
Normal file
39
src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">{t('component.themeSwitcher.toggle')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
{t('component.themeSwitcher.light')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
{t('component.themeSwitcher.dark')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
{t('component.themeSwitcher.system')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
12
src/components/toaster.tsx
Normal file
12
src/components/toaster.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Toaster } from 'sonner';
|
||||
|
||||
export function ToasterComponent() {
|
||||
return (
|
||||
<Toaster
|
||||
theme="dark"
|
||||
position="bottom-right"
|
||||
expand
|
||||
richColors
|
||||
/>
|
||||
);
|
||||
}
|
||||
95
src/components/ui/alert-bar.tsx
Normal file
95
src/components/ui/alert-bar.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X, AlertTriangle, AlertCircle, Info } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertBarVariants = cva(
|
||||
"relative w-full flex items-center gap-3 px-4 py-3 text-sm font-medium",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
info: "bg-blue-500/10 text-blue-700 dark:text-blue-400 border-l-4 border-blue-500",
|
||||
warning:
|
||||
"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 border-l-4 border-yellow-500",
|
||||
error:
|
||||
"bg-red-500/10 text-red-700 dark:text-red-400 border-l-4 border-red-500",
|
||||
critical:
|
||||
"bg-red-600/20 text-red-800 dark:text-red-300 border-l-4 border-red-600",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "info",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface AlertBarProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof alertBarVariants> {
|
||||
dismissable?: boolean;
|
||||
onDismiss?: () => void;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const AlertBar = React.forwardRef<HTMLDivElement, AlertBarProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant,
|
||||
dismissable = true,
|
||||
onDismiss,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isVisible, setIsVisible] = React.useState(true);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false);
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const defaultIcon = React.useMemo(() => {
|
||||
switch (variant) {
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-4 w-4 flex-shrink-0" />;
|
||||
case "error":
|
||||
case "critical":
|
||||
return <AlertCircle className="h-4 w-4 flex-shrink-0" />;
|
||||
case "info":
|
||||
default:
|
||||
return <Info className="h-4 w-4 flex-shrink-0" />;
|
||||
}
|
||||
}, [variant]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(alertBarVariants({ variant }), className)}
|
||||
role="alert"
|
||||
{...props}
|
||||
>
|
||||
{icon ?? defaultIcon}
|
||||
<div className="flex-1">{children}</div>
|
||||
{dismissable && (
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AlertBar.displayName = "AlertBar";
|
||||
|
||||
export { AlertBar, alertBarVariants };
|
||||
48
src/components/ui/avatar.tsx
Normal file
48
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<any, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp: any = asChild ? Slot : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
120
src/components/ui/dialog.tsx
Normal file
120
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border-2 bg-background p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
198
src/components/ui/dropdown-menu.tsx
Normal file
198
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
23
src/components/ui/label.tsx
Normal file
23
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.LabelHTMLAttributes<HTMLLabelElement> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = "Label"
|
||||
|
||||
export { Label }
|
||||
46
src/components/ui/scroll-area.tsx
Normal file
46
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden group", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-1.5 border-l border-l-transparent p-[1px] opacity-0 group-hover:opacity-100 transition-opacity duration-150",
|
||||
orientation === "horizontal" &&
|
||||
"h-1.5 flex-col border-t border-t-transparent p-[1px] opacity-0 group-hover:opacity-100 transition-opacity duration-150",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-muted/40 hover:bg-muted/70 transition-colors" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
118
src/components/ui/select.tsx
Normal file
118
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-primary/10 bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95',
|
||||
position === 'popper' && 'translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1">
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
};
|
||||
29
src/components/ui/separator.tsx
Normal file
29
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
117
src/components/ui/table.tsx
Normal file
117
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
322
src/context/AuthContext.tsx
Normal file
322
src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { VRChatService } from "../services/vrchat";
|
||||
import { AccountService } from "../services/account";
|
||||
import { WebSocketService } from "../services/websocket";
|
||||
import { userStore, accountsStore } from "@/stores";
|
||||
import { setActiveAccountId } from "@/stores/account-scope";
|
||||
import type { User } from "../types/bindings";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
twoFactorMethods: string[];
|
||||
login: (
|
||||
email: string,
|
||||
password: string
|
||||
) => Promise<"success" | "needs_2fa" | "error">;
|
||||
verify2FA: (code: string, method: string) => Promise<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
clearLocalSession: () => Promise<void>;
|
||||
setUser: (user: User | null) => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUserState] = useState<User | null>(userStore.getSnapshot());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [twoFactorMethods, setTwoFactorMethods] = useState<string[]>([]);
|
||||
|
||||
const checkAuth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Eagerly load accounts cache first to avoid loading spinner in AccountSwitcher
|
||||
// Ensure cache is populated before UI renders
|
||||
await accountsStore.ensure().catch(() => undefined);
|
||||
|
||||
if (userStore.getSnapshot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedUser = await AccountService.loadLastAccount();
|
||||
if (savedUser) {
|
||||
setActiveAccountId(savedUser.id);
|
||||
userStore.set(savedUser, { stale: true });
|
||||
|
||||
WebSocketService.start().catch((err) =>
|
||||
console.error("Failed to start WebSocket:", err)
|
||||
);
|
||||
|
||||
// Warm the cache in the background
|
||||
userStore.ensure({ force: true }).catch(() => undefined);
|
||||
userStore.ensureFriends({ force: true }).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSession = await VRChatService.checkSession();
|
||||
if (hasSession) {
|
||||
const currentUser = await userStore.refresh();
|
||||
setActiveAccountId(currentUser.id);
|
||||
|
||||
WebSocketService.start().catch((err) =>
|
||||
console.error("Failed to start WebSocket:", err)
|
||||
);
|
||||
|
||||
userStore.ensureFriends({ force: true }).catch(() => undefined);
|
||||
} else {
|
||||
userStore.clear();
|
||||
userStore.clearFriends();
|
||||
setActiveAccountId(null);
|
||||
}
|
||||
} catch {
|
||||
userStore.clear();
|
||||
userStore.clearFriends();
|
||||
setActiveAccountId(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = userStore.subscribe((value) => {
|
||||
setUserState(value);
|
||||
});
|
||||
checkAuth();
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
WebSocketService.stop().catch(() => undefined);
|
||||
};
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !(window as any).__TAURI_IPC__) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
const unlistenFns: Array<() => void> = [];
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
|
||||
const addListener = async (event: string, handler: () => void) => {
|
||||
const unlisten = await listen(event, () => {
|
||||
if (!isDisposed) {
|
||||
handler();
|
||||
}
|
||||
});
|
||||
unlistenFns.push(unlisten);
|
||||
};
|
||||
|
||||
const refreshCurrentUser = () => {
|
||||
userStore.refresh().catch((error) => {
|
||||
console.error(`Failed to refresh user after ${"event"}`, error);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshFriends = () => {
|
||||
userStore.refreshFriends().catch((error) => {
|
||||
console.error("Failed to refresh friends after event", error);
|
||||
});
|
||||
};
|
||||
|
||||
await addListener("user-update", refreshCurrentUser);
|
||||
await addListener("user-location", refreshCurrentUser);
|
||||
|
||||
// TODO: Strongly type these events
|
||||
const friendEvents = [
|
||||
"friend-added",
|
||||
"friend-update",
|
||||
"friend-online",
|
||||
"friend-active",
|
||||
"friend-offline",
|
||||
"friend-location",
|
||||
"friend-removed",
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
friendEvents.map((event) => addListener(event, refreshFriends))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to set up Tauri event listeners", error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
for (const unlisten of unlistenFns) {
|
||||
try {
|
||||
unlisten();
|
||||
} catch (error) {
|
||||
console.error("Failed to remove Tauri event listener", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = async (
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<"success" | "needs_2fa" | "error"> => {
|
||||
const response = await VRChatService.login(email, password);
|
||||
|
||||
if (response.type === "Success") {
|
||||
// First switch active account ID, then cache user data
|
||||
// This ensures userStore.set() emits to subscribers
|
||||
setActiveAccountId(response.user.id);
|
||||
userStore.set(response.user, { stale: true, scopeId: response.user.id });
|
||||
|
||||
let currentUser = response.user;
|
||||
try {
|
||||
currentUser = await userStore.refresh();
|
||||
await userStore.ensureFriends({ force: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh user after login", error);
|
||||
userStore.set(response.user, { scopeId: response.user.id });
|
||||
userStore.ensureFriends({ force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
setTwoFactorMethods([]);
|
||||
await accountsStore.saveFromUser(currentUser).catch((error) => {
|
||||
console.error("Failed to save account after login", error);
|
||||
});
|
||||
await accountsStore.refresh();
|
||||
WebSocketService.start().catch((err) =>
|
||||
console.error("Failed to start WebSocket:", err)
|
||||
);
|
||||
return "success";
|
||||
} else if (response.type === "TwoFactorRequired") {
|
||||
setTwoFactorMethods(response.methods);
|
||||
return "needs_2fa";
|
||||
}
|
||||
return "error";
|
||||
};
|
||||
|
||||
const verify2FA = async (code: string, method: string): Promise<boolean> => {
|
||||
const verified = await VRChatService.verify2FA(code, method);
|
||||
|
||||
if (verified) {
|
||||
let currentUser: User | null = null;
|
||||
try {
|
||||
currentUser = await userStore.refresh();
|
||||
await userStore.ensureFriends({ force: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh user after 2FA", error);
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
try {
|
||||
currentUser = await VRChatService.getCurrentUser();
|
||||
// Set active account ID before setting user in store to ensure emit happens
|
||||
setActiveAccountId(currentUser.id);
|
||||
userStore.set(currentUser, { scopeId: currentUser.id });
|
||||
userStore.ensureFriends({ force: true }).catch(() => undefined);
|
||||
} catch (fetchError) {
|
||||
console.error("Failed to fetch current user after 2FA", fetchError);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
setActiveAccountId(currentUser.id);
|
||||
userStore.ensureFriends({ force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
setTwoFactorMethods([]);
|
||||
await accountsStore.saveFromUser(currentUser).catch((error) => {
|
||||
console.error("Failed to save account after 2FA", error);
|
||||
});
|
||||
await accountsStore.refresh();
|
||||
WebSocketService.start().catch((err) =>
|
||||
console.error("Failed to start WebSocket:", err)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const currentUserId = user?.id;
|
||||
try {
|
||||
await VRChatService.logout();
|
||||
} catch (error) {
|
||||
console.error("Logout failed", error);
|
||||
} finally {
|
||||
userStore.clear();
|
||||
userStore.clearFriends();
|
||||
setActiveAccountId(null);
|
||||
if (currentUserId) {
|
||||
await accountsStore.removeAccount(currentUserId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearLocalSession = async () => {
|
||||
try {
|
||||
await WebSocketService.stop();
|
||||
await VRChatService.clearSession();
|
||||
} catch {
|
||||
// Ignore errors when clearing session
|
||||
}
|
||||
userStore.clear();
|
||||
userStore.clearFriends();
|
||||
setActiveAccountId(null);
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
try {
|
||||
const refreshedUser = await userStore.refresh();
|
||||
setActiveAccountId(refreshedUser.id);
|
||||
} catch {
|
||||
userStore.clear();
|
||||
userStore.clearFriends();
|
||||
setActiveAccountId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const setUser = (value: User | null) => {
|
||||
if (value) {
|
||||
// Set active account ID before setting user in store to ensure emit happens
|
||||
setActiveAccountId(value.id);
|
||||
userStore.set(value, { scopeId: value.id });
|
||||
} else {
|
||||
userStore.clear();
|
||||
userStore.clearFriends();
|
||||
setActiveAccountId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
loading,
|
||||
twoFactorMethods,
|
||||
login,
|
||||
verify2FA,
|
||||
logout,
|
||||
clearLocalSession,
|
||||
setUser,
|
||||
refreshUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
29
src/devtools-animations.css
Normal file
29
src/devtools-animations.css
Normal file
@@ -0,0 +1,29 @@
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0.7;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.devtools-slide-up {
|
||||
animation: slide-up 0.4s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.devtools-slide-down {
|
||||
animation: slide-down 0.4s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
}
|
||||
118
src/hooks/useCachedImage.ts
Normal file
118
src/hooks/useCachedImage.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { convertFileSrc, isTauri } from "@tauri-apps/api/core";
|
||||
import { ImageCacheService } from "@/services/image-cache";
|
||||
import { logger, LogLevel } from "@/utils/logger";
|
||||
|
||||
// In-memory cache to prevent redundant cache checks across component re-renders
|
||||
const imageCache = new Map<string, string | null>();
|
||||
const pendingChecks = new Map<string, Promise<string | null>>();
|
||||
|
||||
export function useCachedImage(url?: string | null): string | null {
|
||||
const [cached, setCached] = useState<string | null>(() => {
|
||||
if (!url || url.trim().length === 0 || url.startsWith("data:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return imageCache.get(url) ?? null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!url || url.trim().length === 0 || url.startsWith("data:")) {
|
||||
setCached(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
if (imageCache.has(url)) {
|
||||
const cachedValue = imageCache.get(url);
|
||||
if (!cancelled && cachedValue !== undefined) {
|
||||
setCached(cachedValue);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const isInTauri = isTauri();
|
||||
|
||||
if (!isInTauri) {
|
||||
imageCache.set(url, url);
|
||||
setCached(url);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const existingCheck = pendingChecks.get(url);
|
||||
if (existingCheck) {
|
||||
existingCheck.then((result) => {
|
||||
if (!cancelled) {
|
||||
setCached(result);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
// Create new cache check promise
|
||||
const checkPromise = (async () => {
|
||||
try {
|
||||
const cachedPath = await ImageCacheService.checkCached(url);
|
||||
|
||||
if (cachedPath) {
|
||||
const convertedPath = convertFileSrc(cachedPath);
|
||||
imageCache.set(url, convertedPath);
|
||||
return convertedPath;
|
||||
} else {
|
||||
logger.log(
|
||||
"ImageCache",
|
||||
LogLevel.DEBUG,
|
||||
`Cache MISS: ${url}, using original URL`
|
||||
);
|
||||
imageCache.set(url, url);
|
||||
|
||||
try {
|
||||
const path = await ImageCacheService.cache(url);
|
||||
const convertedPath = convertFileSrc(path);
|
||||
|
||||
logger.debug("ImageCache", `Cached: ${url} -> ${convertedPath}`, {
|
||||
path,
|
||||
});
|
||||
imageCache.set(url, convertedPath);
|
||||
|
||||
if (!cancelled) {
|
||||
setCached(convertedPath);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("ImageCache", `Failed to cache: ${url}`, err);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("ImageCache", `Cache check failed: ${url}`, err);
|
||||
imageCache.set(url, url);
|
||||
return url;
|
||||
} finally {
|
||||
pendingChecks.delete(url);
|
||||
}
|
||||
})();
|
||||
|
||||
pendingChecks.set(url, checkPromise);
|
||||
|
||||
checkPromise.then((result) => {
|
||||
if (!cancelled) {
|
||||
setCached(result);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return cached;
|
||||
}
|
||||
41
src/hooks/useToast.ts
Normal file
41
src/hooks/useToast.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
success: (message: string, description?: string) => {
|
||||
toast.success(message, {
|
||||
description,
|
||||
});
|
||||
},
|
||||
error: (message: string, description?: string) => {
|
||||
toast.error(message, {
|
||||
description,
|
||||
});
|
||||
},
|
||||
info: (message: string, description?: string) => {
|
||||
toast.info(message, {
|
||||
description,
|
||||
});
|
||||
},
|
||||
warning: (message: string, description?: string) => {
|
||||
toast.warning(message, {
|
||||
description,
|
||||
});
|
||||
},
|
||||
loading: (message: string, description?: string) => {
|
||||
return toast.loading(message, {
|
||||
description,
|
||||
});
|
||||
},
|
||||
promise: <T,>(
|
||||
promise: Promise<T>,
|
||||
messages: {
|
||||
loading: string;
|
||||
success: string;
|
||||
error: string;
|
||||
}
|
||||
) => {
|
||||
return toast.promise(promise, messages);
|
||||
},
|
||||
};
|
||||
}
|
||||
90
src/hooks/useVRChatStatus.ts
Normal file
90
src/hooks/useVRChatStatus.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { commands } from "@/types/bindings";
|
||||
import type { SystemStatus } from "@/types/bindings";
|
||||
|
||||
interface UseVRChatStatusOptions {
|
||||
/** Polling interval in milliseconds */
|
||||
pollInterval?: number;
|
||||
/** Whether to start polling immediately (default: true) */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseVRChatStatusReturn {
|
||||
status: SystemStatus | null;
|
||||
statusPageUrl: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: Date | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useVRChatStatus(
|
||||
options: UseVRChatStatusOptions = {}
|
||||
): UseVRChatStatusReturn {
|
||||
const { pollInterval = 60000, enabled = true } = options;
|
||||
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [statusPageUrl, setStatusPageUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log("[VRChatStatus] Fetching status...");
|
||||
const result = await commands.getVrchatStatus();
|
||||
|
||||
if (result.status === "ok") {
|
||||
const response = result.data;
|
||||
console.log("[VRChatStatus] Status received:", response.status);
|
||||
setStatus(response.status);
|
||||
setStatusPageUrl(response.page.url);
|
||||
setLastUpdated(new Date());
|
||||
} else {
|
||||
console.error("[VRChatStatus] Error response:", result.error);
|
||||
setError(result.error || "Failed to fetch VRChat status");
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
setError(errorMessage);
|
||||
console.error("[VRChatStatus] Exception:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchStatus();
|
||||
|
||||
if (pollInterval > 0) {
|
||||
intervalRef.current = window.setInterval(fetchStatus, pollInterval);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current !== null) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, pollInterval, fetchStatus]);
|
||||
|
||||
return {
|
||||
status,
|
||||
statusPageUrl,
|
||||
isLoading,
|
||||
error,
|
||||
lastUpdated,
|
||||
refresh: fetchStatus,
|
||||
};
|
||||
}
|
||||
37
src/i18n/config.ts
Normal file
37
src/i18n/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import enTranslation from './locales/en.json';
|
||||
import jaTranslation from './locales/ja.json';
|
||||
import thTranslation from './locales/th.json';
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
translation: enTranslation,
|
||||
},
|
||||
ja: {
|
||||
translation: jaTranslation,
|
||||
},
|
||||
th: {
|
||||
translation: thTranslation,
|
||||
},
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
debug: false,
|
||||
// interpolation: {
|
||||
// escapeValue: false,
|
||||
// },
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
265
src/i18n/locales/en.json
Normal file
265
src/i18n/locales/en.json
Normal file
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"layout": {
|
||||
"sidebar": {
|
||||
"appName": "VRC Circle",
|
||||
"home": "Home",
|
||||
"worlds": "Worlds",
|
||||
"avatars": "Avatars",
|
||||
"logs": "Logs",
|
||||
"developerTools": "Developer Tools"
|
||||
},
|
||||
"friendsSidebar": {
|
||||
"title": "Friends",
|
||||
"collapse": "Collapse friends",
|
||||
"expand": "Expand friends",
|
||||
"location": {
|
||||
"offline": "Offline",
|
||||
"private": "Private instance",
|
||||
"traveling": "Traveling",
|
||||
"group": "Group instance",
|
||||
"instance": "In world instance"
|
||||
},
|
||||
"status": {
|
||||
"website": "On website"
|
||||
},
|
||||
"sections": {
|
||||
"inWorld": {
|
||||
"title": "In World",
|
||||
"empty": "No friends in a world right now"
|
||||
},
|
||||
"active": {
|
||||
"title": "Active",
|
||||
"empty": "No friends active on VRChat"
|
||||
},
|
||||
"offline": {
|
||||
"title": "Offline",
|
||||
"empty": "No friends offline"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"collapsed": "No friends to display"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Failed to load friends"
|
||||
},
|
||||
"onlineCount": "{{count}} online"
|
||||
},
|
||||
"developerTools": {
|
||||
"tabs": {
|
||||
"logger": "Logger",
|
||||
"databaseStudio": "Database Studio",
|
||||
"storeStudio": "Store Studio",
|
||||
"imageCache": "Image Cache"
|
||||
}
|
||||
}
|
||||
},
|
||||
"component": {
|
||||
"developerTools": {
|
||||
"logger": {
|
||||
"title": "Logger",
|
||||
"sidebar": {
|
||||
"refresh": "Refresh",
|
||||
"autoRefresh": "Auto-refresh",
|
||||
"export": "Export",
|
||||
"clearAll": "Clear All",
|
||||
"searchLabel": "Search",
|
||||
"searchPlaceholder": "Search...",
|
||||
"source": "Source",
|
||||
"level": "Level",
|
||||
"counts": "{{filtered}} / {{total}} logs"
|
||||
},
|
||||
"main": {
|
||||
"title": "Log Entries",
|
||||
"description": "View and filter application logs from frontend and backend",
|
||||
"empty": "No logs found matching the current filters"
|
||||
},
|
||||
"toasts": {
|
||||
"refreshed": "Logs refreshed",
|
||||
"refreshFailed": "Failed to refresh logs",
|
||||
"cleared": "All logs cleared",
|
||||
"clearFailed": "Failed to clear logs",
|
||||
"exported": "Logs exported successfully",
|
||||
"exportFailed": "Failed to export logs"
|
||||
},
|
||||
"confirm": {
|
||||
"clearAll": "Are you sure you want to clear all logs?"
|
||||
}
|
||||
},
|
||||
"databaseStudio": {
|
||||
"sidebar": {
|
||||
"title": "Database Studio",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"tabs": {
|
||||
"browse": "Browse Data",
|
||||
"query": "Custom Query"
|
||||
},
|
||||
"browse": {
|
||||
"noTableSelected": {
|
||||
"title": "No Table Selected",
|
||||
"description": "Select a table from the sidebar to view its data"
|
||||
},
|
||||
"schema": {
|
||||
"title": "Schema",
|
||||
"show": "Show",
|
||||
"hide": "Hide",
|
||||
"hidden": "{{count}} column(s) hidden"
|
||||
},
|
||||
"data": {
|
||||
"title": "Data",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageInfo": "Page {{page}} of {{total}}",
|
||||
"loading": "Loading...",
|
||||
"noData": "No data available",
|
||||
"table": {
|
||||
"noRows": "No rows in this table",
|
||||
"actions": "Actions",
|
||||
"saveTitle": "Save changes",
|
||||
"deleteTitle": "Delete row"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
"loadTablesFailed": "Failed to load database tables",
|
||||
"loadTableDataFailed": "Failed to load table data",
|
||||
"enterQuery": "Please enter a query",
|
||||
"queryExecuted": "Query executed successfully",
|
||||
"queryFailed": "Query failed: {{message}}",
|
||||
"cannotUpdateNoPK": "Cannot update: No primary key found",
|
||||
"rowUpdated": "Row updated successfully",
|
||||
"updateFailed": "Failed to update row: {{message}}",
|
||||
"cannotDeleteNoPK": "Cannot delete: No primary key found",
|
||||
"rowDeleted": "Row deleted successfully",
|
||||
"deleteFailed": "Failed to delete row: {{message}}"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteRow": "Delete row where {{column}} = {{value}}?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loginRequired": {
|
||||
"title": "Login Required",
|
||||
"bottomText": "Sign in to your VRChat account to access this feature.",
|
||||
"signIn": "Sign In"
|
||||
},
|
||||
"accountMenu": {
|
||||
"logout": "Logout",
|
||||
"setStatus": "Set Status",
|
||||
"customStatusPlaceholder": "Custom status message...",
|
||||
"setCustomStatus": "Set Custom Status",
|
||||
"clearStatus": "Clear status",
|
||||
"statusCopied": "Status copied to clipboard!",
|
||||
"statusCopyFailed": "Failed to copy status",
|
||||
"viewProfile": "View Profile",
|
||||
"switchAccount": "Switch Account",
|
||||
"addAccount": "Add Account",
|
||||
"settings": "Settings",
|
||||
"statusUpdateFailed": "Failed to update status. Please try again.",
|
||||
"accountSwitchFailed": "Failed to switch account. Please try again.",
|
||||
"unknownUser": "Unknown User",
|
||||
"unknownUsername": "Unknown"
|
||||
},
|
||||
"themeSwitcher": {
|
||||
"toggle": "Toggle theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"login": {
|
||||
"title": "Sign in",
|
||||
"subtitle": "Sign in to your VRChat account",
|
||||
"welcomeTitle": "Welcome back",
|
||||
"welcomeDescription": "Enter your credentials to continue",
|
||||
"placeholderEmail": "vrcat@example.com",
|
||||
"placeholderPassword": "••••••••",
|
||||
"signingIn": "Signing in...",
|
||||
"signInButton": "Sign In",
|
||||
"skip": "Skip logging in"
|
||||
},
|
||||
"worlds": {
|
||||
"title": "Worlds",
|
||||
"description": "Browse and manage your uploaded VRChat worlds",
|
||||
"showingSingle": "Showing {{count}} world",
|
||||
"showingMultiple": "Showing {{count}} worlds",
|
||||
"refresh": {
|
||||
"loading": "Loading...",
|
||||
"label": "Refresh"
|
||||
},
|
||||
"error": {
|
||||
"title": "Unable to load worlds",
|
||||
"tryAgain": "Try again"
|
||||
},
|
||||
"noItemsTitle": "No worlds yet",
|
||||
"noItemsDescription": "We couldn't find any uploaded worlds for this account. Upload a world to VRChat to see it here.",
|
||||
"noPreview": "No preview available",
|
||||
"byAuthor": "By {{author}}",
|
||||
"authorUnknown": "Author unknown",
|
||||
"labels": {
|
||||
"visits": "Visits",
|
||||
"favorites": "Favorites",
|
||||
"capacity": "Capacity",
|
||||
"updatedPrefix": "Updated {{date}}"
|
||||
}
|
||||
},
|
||||
"avatars": {
|
||||
"title": "Avatars",
|
||||
"description": "Browse and manage your uploaded VRChat avatars",
|
||||
"showingSingle": "Showing {{count}} avatar",
|
||||
"showingMultiple": "Showing {{count}} avatars",
|
||||
"refresh": {
|
||||
"loading": "Loading...",
|
||||
"label": "Refresh"
|
||||
},
|
||||
"error": {
|
||||
"title": "Unable to load avatars",
|
||||
"tryAgain": "Try again"
|
||||
},
|
||||
"noItemsTitle": "No avatars yet",
|
||||
"noItemsDescription": "We couldn't find any uploaded avatars for this account. Upload an avatar to VRChat to see it here.",
|
||||
"noPreview": "No preview available",
|
||||
"byAuthor": "By {{author}}",
|
||||
"authorUnknown": "Author unknown",
|
||||
"labels": {
|
||||
"performance": "Performance",
|
||||
"release": "Release",
|
||||
"updated": "Updated",
|
||||
"version": "Version",
|
||||
"style": "Style",
|
||||
"createdPrefix": "Created {{date}}",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"status": {
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"hidden": "Hidden",
|
||||
"all": "All"
|
||||
}
|
||||
},
|
||||
"verify2fa": {
|
||||
"title": "Two-Factor Authentication",
|
||||
"authenticator": "Authenticator App",
|
||||
"email": "Email Code",
|
||||
"enterCode": "Enter verification code",
|
||||
"appInstruction": "Open your authenticator app and enter the 6-digit code.",
|
||||
"emailInstruction": "Check your email for the one-time code and enter it below.",
|
||||
"code": "Verification Code",
|
||||
"placeholder": "000000",
|
||||
"verifying": "Verifying...",
|
||||
"verify": "Verify",
|
||||
"back": "Back to Sign In",
|
||||
"help": "Can't sign in? Contact support or try another sign-in method.",
|
||||
"invalid": "Invalid code. Please try again."
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"joinMe": "Join Me",
|
||||
"askMe": "Ask Me",
|
||||
"busy": "Busy"
|
||||
}
|
||||
}
|
||||
}
|
||||
265
src/i18n/locales/ja.json
Normal file
265
src/i18n/locales/ja.json
Normal file
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"layout": {
|
||||
"sidebar": {
|
||||
"appName": "VRC Circle",
|
||||
"home": "ホーム",
|
||||
"worlds": "ワールド",
|
||||
"avatars": "アバター",
|
||||
"logs": "ログ",
|
||||
"developerTools": "開発者ツール"
|
||||
},
|
||||
"friendsSidebar": {
|
||||
"title": "フレンド",
|
||||
"collapse": "フレンドを折りたたむ",
|
||||
"expand": "フレンドを展開",
|
||||
"location": {
|
||||
"offline": "オフライン",
|
||||
"private": "プライベートインスタンス",
|
||||
"traveling": "移動中",
|
||||
"group": "グループインスタンス",
|
||||
"instance": "ワールドのインスタンス"
|
||||
},
|
||||
"status": {
|
||||
"website": "ウェブサイト上"
|
||||
},
|
||||
"sections": {
|
||||
"inWorld": {
|
||||
"title": "ワールド内",
|
||||
"empty": "現在ワールドにいるフレンドはいません"
|
||||
},
|
||||
"active": {
|
||||
"title": "アクティブ",
|
||||
"empty": "VRChatでアクティブなフレンドはいません"
|
||||
},
|
||||
"offline": {
|
||||
"title": "オフライン",
|
||||
"empty": "オフラインのフレンドはいません"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"collapsed": "表示するフレンドがいません"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "フレンドの読み込みに失敗しました"
|
||||
},
|
||||
"onlineCount": "{{count}}人オンライン"
|
||||
},
|
||||
"developerTools": {
|
||||
"tabs": {
|
||||
"logger": "ロガー",
|
||||
"databaseStudio": "データベース",
|
||||
"storeStudio": "ストアスタジオ",
|
||||
"imageCache": "画像キャッシュ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"component": {
|
||||
"developerTools": {
|
||||
"logger": {
|
||||
"title": "ロガー",
|
||||
"sidebar": {
|
||||
"refresh": "更新",
|
||||
"autoRefresh": "自動更新",
|
||||
"export": "エクスポート",
|
||||
"clearAll": "全てクリア",
|
||||
"searchLabel": "検索",
|
||||
"searchPlaceholder": "検索...",
|
||||
"source": "ソース",
|
||||
"level": "レベル",
|
||||
"counts": "{{filtered}} / {{total}} 件のログ"
|
||||
},
|
||||
"main": {
|
||||
"title": "ログエントリ",
|
||||
"description": "フロントエンドとバックエンドのアプリケーションログを表示・フィルタリングします",
|
||||
"empty": "現在のフィルタに一致するログはありません"
|
||||
},
|
||||
"toasts": {
|
||||
"refreshed": "ログを更新しました",
|
||||
"refreshFailed": "ログの更新に失敗しました",
|
||||
"cleared": "全てのログをクリアしました",
|
||||
"clearFailed": "ログのクリアに失敗しました",
|
||||
"exported": "ログをエクスポートしました",
|
||||
"exportFailed": "ログのエクスポートに失敗しました"
|
||||
},
|
||||
"confirm": {
|
||||
"clearAll": "全てのログをクリアしてもよろしいですか?"
|
||||
}
|
||||
},
|
||||
"databaseStudio": {
|
||||
"sidebar": {
|
||||
"title": "データベース",
|
||||
"refresh": "更新"
|
||||
},
|
||||
"tabs": {
|
||||
"browse": "データ参照",
|
||||
"query": "カスタムクエリ"
|
||||
},
|
||||
"browse": {
|
||||
"noTableSelected": {
|
||||
"title": "テーブルが選択されていません",
|
||||
"description": "サイドバーからテーブルを選択してデータを表示してください"
|
||||
},
|
||||
"schema": {
|
||||
"title": "スキーマ",
|
||||
"show": "表示",
|
||||
"hide": "非表示",
|
||||
"hidden": "{{count}} 列が非表示"
|
||||
},
|
||||
"data": {
|
||||
"title": "データ",
|
||||
"previous": "前へ",
|
||||
"next": "次へ",
|
||||
"pageInfo": "ページ {{page}} / {{total}}",
|
||||
"loading": "読み込み中...",
|
||||
"noData": "データがありません",
|
||||
"table": {
|
||||
"noRows": "このテーブルに行がありません",
|
||||
"actions": "操作",
|
||||
"saveTitle": "変更を保存",
|
||||
"deleteTitle": "行を削除"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
"loadTablesFailed": "データベースのテーブルの読み込みに失敗しました",
|
||||
"loadTableDataFailed": "テーブルデータの読み込みに失敗しました",
|
||||
"enterQuery": "クエリを入力してください",
|
||||
"queryExecuted": "クエリが正常に実行されました",
|
||||
"queryFailed": "クエリの実行に失敗しました: {{message}}",
|
||||
"cannotUpdateNoPK": "更新できません: 主キーが見つかりません",
|
||||
"rowUpdated": "行を更新しました",
|
||||
"updateFailed": "行の更新に失敗しました: {{message}}",
|
||||
"cannotDeleteNoPK": "削除できません: 主キーが見つかりません",
|
||||
"rowDeleted": "行を削除しました",
|
||||
"deleteFailed": "行の削除に失敗しました: {{message}}"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteRow": "{{column}} = {{value}} の行を削除しますか?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loginRequired": {
|
||||
"title": "ログインが必要です",
|
||||
"bottomText": "この機能にアクセスするには VRChat アカウントでサインインしてください。",
|
||||
"signIn": "サインイン"
|
||||
},
|
||||
"accountMenu": {
|
||||
"logout": "ログアウト",
|
||||
"setStatus": "ステータスを設定",
|
||||
"customStatusPlaceholder": "カスタムステータスメッセージ...",
|
||||
"setCustomStatus": "カスタムステータスを設定",
|
||||
"clearStatus": "ステータスをクリア",
|
||||
"statusCopied": "ステータスをクリップボードにコピーしました!",
|
||||
"statusCopyFailed": "ステータスのコピーに失敗しました",
|
||||
"viewProfile": "プロフィールを見る",
|
||||
"switchAccount": "アカウントを切り替え",
|
||||
"addAccount": "アカウントを追加",
|
||||
"settings": "設定",
|
||||
"statusUpdateFailed": "ステータスの更新に失敗しました。もう一度お試しください。",
|
||||
"accountSwitchFailed": "アカウントの切り替えに失敗しました。もう一度お試しください。",
|
||||
"unknownUser": "不明なユーザー",
|
||||
"unknownUsername": "不明"
|
||||
},
|
||||
"themeSwitcher": {
|
||||
"toggle": "テーマを切り替え",
|
||||
"light": "ライト",
|
||||
"dark": "ダーク",
|
||||
"system": "システム"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"login": {
|
||||
"title": "サインイン",
|
||||
"subtitle": "VRChat アカウントでサインイン",
|
||||
"welcomeTitle": "おかえりなさい",
|
||||
"welcomeDescription": "続行するには資格情報を入力してください",
|
||||
"placeholderEmail": "vrchat@example.com",
|
||||
"placeholderPassword": "••••••••",
|
||||
"signingIn": "サインイン中...",
|
||||
"signInButton": "サインイン",
|
||||
"skip": "ログインをスキップ"
|
||||
},
|
||||
"worlds": {
|
||||
"title": "ワールド",
|
||||
"description": "アップロードした VRChat ワールドを閲覧・管理します",
|
||||
"showingSingle": "{{count}} 件のワールドを表示",
|
||||
"showingMultiple": "{{count}} 件のワールドを表示",
|
||||
"refresh": {
|
||||
"loading": "読み込み中...",
|
||||
"label": "更新"
|
||||
},
|
||||
"error": {
|
||||
"title": "ワールドの読み込みに失敗しました",
|
||||
"tryAgain": "再試行"
|
||||
},
|
||||
"noItemsTitle": "まだワールドがありません",
|
||||
"noItemsDescription": "このアカウントにアップロードされたワールドが見つかりませんでした。ワールドを VRChat にアップロードするとここに表示されます。",
|
||||
"noPreview": "プレビューはありません",
|
||||
"byAuthor": "作成者: {{author}}",
|
||||
"authorUnknown": "作成者不明",
|
||||
"labels": {
|
||||
"visits": "訪問数",
|
||||
"favorites": "お気に入り",
|
||||
"capacity": "容量",
|
||||
"updatedPrefix": "更新日 {{date}}"
|
||||
}
|
||||
},
|
||||
"avatars": {
|
||||
"title": "アバター",
|
||||
"description": "アップロードした VRChat アバターを閲覧・管理します",
|
||||
"showingSingle": "{{count}} 件のアバターを表示",
|
||||
"showingMultiple": "{{count}} 件のアバターを表示",
|
||||
"refresh": {
|
||||
"loading": "読み込み中...",
|
||||
"label": "更新"
|
||||
},
|
||||
"error": {
|
||||
"title": "アバターの読み込みに失敗しました",
|
||||
"tryAgain": "再試行"
|
||||
},
|
||||
"noItemsTitle": "まだアバターがありません",
|
||||
"noItemsDescription": "このアカウントにアップロードされたアバターが見つかりませんでした。アバターを VRChat にアップロードするとここに表示されます。",
|
||||
"noPreview": "プレビューはありません",
|
||||
"byAuthor": "作成者: {{author}}",
|
||||
"authorUnknown": "作成者不明",
|
||||
"labels": {
|
||||
"performance": "パフォーマンス",
|
||||
"release": "リリース",
|
||||
"updated": "更新日",
|
||||
"version": "バージョン",
|
||||
"style": "スタイル",
|
||||
"createdPrefix": "作成日 {{date}}",
|
||||
"unknown": "不明"
|
||||
},
|
||||
"status": {
|
||||
"public": "公開",
|
||||
"private": "プライベート",
|
||||
"hidden": "非表示",
|
||||
"all": "すべて"
|
||||
}
|
||||
},
|
||||
"verify2fa": {
|
||||
"title": "二段階認証",
|
||||
"authenticator": "認証アプリ",
|
||||
"email": "メールコード",
|
||||
"enterCode": "確認コードを入力",
|
||||
"appInstruction": "認証アプリを開き、6 桁のコードを入力してください。",
|
||||
"emailInstruction": "メールに送信されたワンタイムコードを確認し、下に入力してください。",
|
||||
"code": "確認コード",
|
||||
"placeholder": "000000",
|
||||
"verifying": "確認中...",
|
||||
"verify": "確認",
|
||||
"back": "サインインに戻る",
|
||||
"help": "サインインできませんか?サポートに連絡するか別のサインイン方法を試してください。",
|
||||
"invalid": "無効なコードです。もう一度お試しください。"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"status": {
|
||||
"active": "アクティブ",
|
||||
"joinMe": "参加して",
|
||||
"askMe": "聞いて",
|
||||
"busy": "取り込み中"
|
||||
}
|
||||
}
|
||||
}
|
||||
265
src/i18n/locales/th.json
Normal file
265
src/i18n/locales/th.json
Normal file
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"layout": {
|
||||
"sidebar": {
|
||||
"appName": "VRC Circle",
|
||||
"home": "หน้าแรก",
|
||||
"worlds": "เวิลด์",
|
||||
"avatars": "อวาตาร์",
|
||||
"logs": "บันทึก",
|
||||
"developerTools": "เครื่องมือสำหรับนักพัฒนา"
|
||||
},
|
||||
"friendsSidebar": {
|
||||
"title": "เพื่อน",
|
||||
"collapse": "ยุบรายการเพื่อน",
|
||||
"expand": "ขยายรายการเพื่อน",
|
||||
"location": {
|
||||
"offline": "ออฟไลน์",
|
||||
"private": "อินสแตนซ์ส่วนตัว",
|
||||
"traveling": "กำลังเดินทาง",
|
||||
"group": "อินสแตนซ์กลุ่ม",
|
||||
"instance": "อยู่ในอินสแตนซ์ของเวิลด์"
|
||||
},
|
||||
"status": {
|
||||
"website": "อยู่บนเว็บไซต์"
|
||||
},
|
||||
"sections": {
|
||||
"inWorld": {
|
||||
"title": "ในเวิลด์",
|
||||
"empty": "ขณะนี้ไม่มีเพื่อนอยู่ในเวิลด์"
|
||||
},
|
||||
"active": {
|
||||
"title": "กำลังใช้งาน",
|
||||
"empty": "ไม่มีเพื่อนที่กำลังใช้งาน VRChat"
|
||||
},
|
||||
"offline": {
|
||||
"title": "ออฟไลน์",
|
||||
"empty": "ไม่มีเพื่อนที่ออฟไลน์"
|
||||
}
|
||||
},
|
||||
"empty": {
|
||||
"collapsed": "ไม่มีเพื่อนให้แสดง"
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "โหลดเพื่อนไม่สำเร็จ"
|
||||
},
|
||||
"onlineCount": "{{count}} คนออนไลน์"
|
||||
},
|
||||
"developerTools": {
|
||||
"tabs": {
|
||||
"logger": "บันทึก",
|
||||
"databaseStudio": "ฐานข้อมูล",
|
||||
"storeStudio": "สตูดิโอสโตร์",
|
||||
"imageCache": "แคชรูปภาพ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"component": {
|
||||
"developerTools": {
|
||||
"logger": {
|
||||
"title": "บันทึก",
|
||||
"sidebar": {
|
||||
"refresh": "รีเฟรช",
|
||||
"autoRefresh": "รีเฟรชอัตโนมัติ",
|
||||
"export": "ส่งออก",
|
||||
"clearAll": "ล้างทั้งหมด",
|
||||
"searchLabel": "ค้นหา",
|
||||
"searchPlaceholder": "ค้นหา...",
|
||||
"source": "แหล่งที่มา",
|
||||
"level": "ระดับ",
|
||||
"counts": "{{filtered}} / {{total}} บันทึก"
|
||||
},
|
||||
"main": {
|
||||
"title": "รายการบันทึก",
|
||||
"description": "ดูและกรองบันทึกแอปพลิเคชันจากฝั่ง frontend และ backend",
|
||||
"empty": "ไม่พบบันทึกที่ตรงกับตัวกรองปัจจุบัน"
|
||||
},
|
||||
"toasts": {
|
||||
"refreshed": "รีเฟรชบันทึกแล้ว",
|
||||
"refreshFailed": "รีเฟรชบันทึกไม่สำเร็จ",
|
||||
"cleared": "ล้างบันทึกทั้งหมดแล้ว",
|
||||
"clearFailed": "ล้างบันทึกไม่สำเร็จ",
|
||||
"exported": "ส่งออกบันทึกเรียบร้อยแล้ว",
|
||||
"exportFailed": "ส่งออกบันทึกไม่สำเร็จ"
|
||||
},
|
||||
"confirm": {
|
||||
"clearAll": "คุณแน่ใจหรือไม่ว่าต้องการล้างบันทึกทั้งหมด?"
|
||||
}
|
||||
},
|
||||
"databaseStudio": {
|
||||
"sidebar": {
|
||||
"title": "สตูดิโอฐานข้อมูล",
|
||||
"refresh": "รีเฟรช"
|
||||
},
|
||||
"tabs": {
|
||||
"browse": "เรียกดูข้อมูล",
|
||||
"query": "คิวรีที่กำหนดเอง"
|
||||
},
|
||||
"browse": {
|
||||
"noTableSelected": {
|
||||
"title": "ยังไม่ได้เลือกตาราง",
|
||||
"description": "เลือกตารางจากแถบด้านข้างเพื่อดูข้อมูล"
|
||||
},
|
||||
"schema": {
|
||||
"title": "สกีมา",
|
||||
"show": "แสดง",
|
||||
"hide": "ซ่อน",
|
||||
"hidden": "มีคอลัมน์ที่ซ่อนอยู่ {{count}} คอลัมน์"
|
||||
},
|
||||
"data": {
|
||||
"title": "ข้อมูล",
|
||||
"previous": "ก่อนหน้า",
|
||||
"next": "ถัดไป",
|
||||
"pageInfo": "หน้า {{page}} จาก {{total}}",
|
||||
"loading": "กำลังโหลด...",
|
||||
"noData": "ไม่มีข้อมูล",
|
||||
"table": {
|
||||
"noRows": "ไม่มีแถวในตารางนี้",
|
||||
"actions": "การกระทำ",
|
||||
"saveTitle": "บันทึกการเปลี่ยนแปลง",
|
||||
"deleteTitle": "ลบแถว"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
"loadTablesFailed": "โหลดตารางฐานข้อมูลไม่สำเร็จ",
|
||||
"loadTableDataFailed": "โหลดข้อมูลตารางไม่สำเร็จ",
|
||||
"enterQuery": "กรุณาป้อนคำสั่งคิวรี",
|
||||
"queryExecuted": "คิวรีดำเนินการสำเร็จ",
|
||||
"queryFailed": "คิวรีล้มเหลว: {{message}}",
|
||||
"cannotUpdateNoPK": "ไม่สามารถอัปเดต: ไม่พบ primary key",
|
||||
"rowUpdated": "อัปเดตแถวสำเร็จ",
|
||||
"updateFailed": "อัปเดตแถวไม่สำเร็จ: {{message}}",
|
||||
"cannotDeleteNoPK": "ไม่สามารถลบ: ไม่พบ primary key",
|
||||
"rowDeleted": "ลบแถวสำเร็จ",
|
||||
"deleteFailed": "ลบแถวไม่สำเร็จ: {{message}}"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteRow": "ต้องการลบแถวที่ {{column}} = {{value }} หรือไม่"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loginRequired": {
|
||||
"title": "ต้องเข้าสู่ระบบ",
|
||||
"bottomText": "ลงชื่อเข้าใช้บัญชี VRChat ของคุณเพื่อเข้าถึงฟีเจอร์นี้",
|
||||
"signIn": "ลงชื่อเข้าใช้"
|
||||
},
|
||||
"accountMenu": {
|
||||
"logout": "ออกจากระบบ",
|
||||
"setStatus": "ตั้งค่าสถานะ",
|
||||
"customStatusPlaceholder": "ข้อความสถานะแบบกำหนดเอง...",
|
||||
"setCustomStatus": "ตั้งค่าสถานะแบบกำหนดเอง",
|
||||
"clearStatus": "ล้างสถานะ",
|
||||
"statusCopied": "คัดลอกสถานะไปยังคลิปบอร์ดแล้ว!",
|
||||
"statusCopyFailed": "คัดลอกสถานะไม่สำเร็จ",
|
||||
"viewProfile": "ดูโปรไฟล์",
|
||||
"switchAccount": "สลับบัญชี",
|
||||
"addAccount": "เพิ่มบัญชี",
|
||||
"settings": "การตั้งค่า",
|
||||
"statusUpdateFailed": "อัปเดตสถานะไม่สำเร็จ กรุณาลองอีกครั้ง",
|
||||
"accountSwitchFailed": "สลับบัญชีไม่สำเร็จ กรุณาลองอีกครั้ง",
|
||||
"unknownUser": "ผู้ใช้ไม่ทราบ",
|
||||
"unknownUsername": "ชื่อผู้ใช้ไม่ทราบ"
|
||||
},
|
||||
"themeSwitcher": {
|
||||
"toggle": "สลับธีม",
|
||||
"light": "สว่าง",
|
||||
"dark": "มืด",
|
||||
"system": "ระบบ"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"login": {
|
||||
"title": "ลงชื่อเข้าใช้",
|
||||
"subtitle": "ลงชื่อเข้าใช้ด้วยบัญชี VRChat ของคุณ",
|
||||
"welcomeTitle": "ยินดีต้อนรับกลับ",
|
||||
"welcomeDescription": "กรอกข้อมูลประจำตัวของคุณเพื่อดำเนินการต่อ",
|
||||
"placeholderEmail": "you@example.com",
|
||||
"placeholderPassword": "••••••••",
|
||||
"signingIn": "กำลังลงชื่อเข้าใช้...",
|
||||
"signInButton": "ลงชื่อเข้าใช้",
|
||||
"skip": "ข้ามการลงชื่อเข้าใช้"
|
||||
},
|
||||
"worlds": {
|
||||
"title": "เวิลด์",
|
||||
"description": "เรียกดูและจัดการเวิลด์ VRChat ที่คุณอัปโหลด",
|
||||
"showingSingle": "แสดง {{count}} เวิลด์",
|
||||
"showingMultiple": "แสดง {{count}} เวิลด์",
|
||||
"refresh": {
|
||||
"loading": "กำลังโหลด...",
|
||||
"label": "รีเฟรช"
|
||||
},
|
||||
"error": {
|
||||
"title": "ไม่สามารถโหลดเวิลด์ได้",
|
||||
"tryAgain": "ลองอีกครั้ง"
|
||||
},
|
||||
"noItemsTitle": "ยังไม่มีเวิลด์",
|
||||
"noItemsDescription": "ไม่พบเวิลด์ที่อัปโหลดสำหรับบัญชีนี้ อัปโหลดเวิลด์ไปยัง VRChat เพื่อให้ปรากฏที่นี่",
|
||||
"noPreview": "ไม่มีตัวอย่าง",
|
||||
"byAuthor": "โดย {{author}}",
|
||||
"authorUnknown": "ผู้สร้างไม่ทราบ",
|
||||
"labels": {
|
||||
"visits": "ผู้เข้าชม",
|
||||
"favorites": "บุ๊คมาร์ก",
|
||||
"capacity": "ความจุ",
|
||||
"updatedPrefix": "อัปเดต {{date}}"
|
||||
}
|
||||
},
|
||||
"avatars": {
|
||||
"title": "อวาตาร์",
|
||||
"description": "เรียกดูและจัดการอวาตาร์ VRChat ที่คุณอัปโหลด",
|
||||
"showingSingle": "แสดง {{count}} อวาตาร์",
|
||||
"showingMultiple": "แสดง {{count}} อวาตาร์",
|
||||
"refresh": {
|
||||
"loading": "กำลังโหลด...",
|
||||
"label": "รีเฟรช"
|
||||
},
|
||||
"error": {
|
||||
"title": "ไม่สามารถโหลดอวาตาร์ได้",
|
||||
"tryAgain": "ลองอีกครั้ง"
|
||||
},
|
||||
"noItemsTitle": "ยังไม่มีอวาตาร์",
|
||||
"noItemsDescription": "ไม่พบอวาตาร์ที่อัปโหลดสำหรับบัญชีนี้ อัปโหลดอวาตาร์ไปยัง VRChat เพื่อให้ปรากฏที่นี่",
|
||||
"noPreview": "ไม่มีตัวอย่าง",
|
||||
"byAuthor": "โดย {{author}}",
|
||||
"authorUnknown": "ผู้สร้างไม่ทราบ",
|
||||
"labels": {
|
||||
"performance": "ประสิทธิภาพ",
|
||||
"release": "การปล่อย",
|
||||
"updated": "อัปเดต",
|
||||
"version": "เวอร์ชัน",
|
||||
"style": "สไตล์",
|
||||
"createdPrefix": "สร้างเมื่อ {{date}}",
|
||||
"unknown": "ไม่ทราบ"
|
||||
},
|
||||
"status": {
|
||||
"public": "สาธารณะ",
|
||||
"private": "ส่วนตัว",
|
||||
"hidden": "ซ่อน",
|
||||
"all": "ทั้งหมด"
|
||||
}
|
||||
},
|
||||
"verify2fa": {
|
||||
"title": "การยืนยันแบบสองปัจจัย",
|
||||
"authenticator": "แอปตัวตรวจสอบสิทธิ์",
|
||||
"email": "รหัสทางอีเมล",
|
||||
"enterCode": "ป้อนรหัสยืนยัน",
|
||||
"appInstruction": "เปิดแอปตัวตรวจสอบสิทธิ์ของคุณแล้วป้อนรหัส 6 หลัก",
|
||||
"emailInstruction": "ตรวจสอบอีเมลของคุณสำหรับรหัสแบบครั้งเดียวแล้วป้อนด้านล่าง",
|
||||
"code": "รหัสยืนยัน",
|
||||
"placeholder": "000000",
|
||||
"verifying": "กำลังยืนยัน...",
|
||||
"verify": "ยืนยัน",
|
||||
"back": "กลับไปที่การเข้าสู่ระบบ",
|
||||
"help": "ไม่สามารถลงชื่อเข้าใช้ได้หรือไม่? ติดต่อฝ่ายสนับสนุนหรือลองวิธีการลงชื่อเข้าใช้อื่น",
|
||||
"invalid": "รหัสไม่ถูกต้อง กรุณาลองอีกครั้ง"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"status": {
|
||||
"active": "กำลังใช้งาน",
|
||||
"joinMe": "เข้าร่วมฉัน",
|
||||
"askMe": "ถามฉัน",
|
||||
"busy": "ไม่ว่าง"
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/index.css
Normal file
59
src/index.css
Normal file
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 262.1 83.3% 57.8%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 262.1 83.3% 57.8%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 263.4 70% 50.4%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 263.4 70% 50.4%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
123
src/layouts/MainLayout.tsx
Normal file
123
src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
import { FriendsSidebar } from "@/components/FriendsSidebar";
|
||||
import { AlertBarContainer } from "@/components/AlertBarContainer";
|
||||
import { DevTools } from "@/components/DevTools";
|
||||
import { Home, Globe, Palette, Menu } from "lucide-react";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { AccountMenu } from "@/components/AccountMenu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { vrchatStatusStore } from "@/stores";
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MainLayout({ children }: MainLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [friendsSidebarOpen, setFriendsSidebarOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Start VRChat status polling
|
||||
// TODO: Move this too somewhere else?
|
||||
useEffect(() => {
|
||||
vrchatStatusStore.startPolling(5 * 60 * 1000);
|
||||
|
||||
return () => {
|
||||
vrchatStatusStore.stopPolling();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleNavigation = (path: string) => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
const mainItems = [
|
||||
{ id: "home", label: t("layout.sidebar.home"), icon: Home, path: "/" },
|
||||
{
|
||||
id: "worlds",
|
||||
label: t("layout.sidebar.worlds"),
|
||||
icon: Globe,
|
||||
path: "/worlds",
|
||||
},
|
||||
{
|
||||
id: "avatars",
|
||||
label: t("layout.sidebar.avatars"),
|
||||
icon: Palette,
|
||||
path: "/avatars",
|
||||
},
|
||||
];
|
||||
|
||||
const bottomItems: typeof mainItems = [];
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-background flex flex-col overflow-hidden">
|
||||
{/* Alert Bar */}
|
||||
<AlertBarContainer />
|
||||
|
||||
{/* Main layout */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar */}
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||
mainItems={mainItems}
|
||||
bottomItems={bottomItems}
|
||||
isActive={isActive}
|
||||
onNavigate={handleNavigation}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
{/* Navbar */}
|
||||
{user ? (
|
||||
<Navbar onMenuToggle={() => setSidebarOpen(!sidebarOpen)} />
|
||||
) : (
|
||||
<nav className="h-16 bg-card border-b border-border flex items-center justify-between px-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
|
||||
{/* Right Friends Sidebar */}
|
||||
{user && (
|
||||
<FriendsSidebar
|
||||
isOpen={friendsSidebarOpen}
|
||||
onToggle={() => setFriendsSidebarOpen(!friendsSidebarOpen)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DevTools Overlay */}
|
||||
<DevTools />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/lib/user.ts
Normal file
37
src/lib/user.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { User } from '@/types/bindings';
|
||||
|
||||
export function getUserInitials(displayName?: string | null): string {
|
||||
if (!displayName) {
|
||||
return '??';
|
||||
}
|
||||
|
||||
return displayName
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
export function getUserAvatarUrl(user?: Partial<User> | null): string | null {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isValid = (url?: string | null): url is string => {
|
||||
return !!url && url.trim().length > 0;
|
||||
};
|
||||
|
||||
// Priority order:
|
||||
// 1. userIcon, User's profile icon (preferred)
|
||||
if (isValid(user.userIcon)) return user.userIcon;
|
||||
|
||||
// 2. currentAvatarThumbnailImageUrl, Current avatar thumbnail
|
||||
if (isValid(user.currentAvatarThumbnailImageUrl)) return user.currentAvatarThumbnailImageUrl;
|
||||
|
||||
// 3. currentAvatarImageUrl, Current avatar full image
|
||||
if (isValid(user.currentAvatarImageUrl)) return user.currentAvatarImageUrl;
|
||||
|
||||
return null;
|
||||
}
|
||||
121
src/lib/utils.ts
Normal file
121
src/lib/utils.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import type { User, LimitedUserFriend } from "@/types/bindings"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when the user should be treated as offline.
|
||||
*
|
||||
* The logic prefers an explicit `state` when present, falls back to\
|
||||
* `status` for web users, and finally inspects `location` for world/traveling/private indicators.
|
||||
*/
|
||||
export function isUserOffline(user: User | LimitedUserFriend | null | undefined): boolean {
|
||||
if (!user) return true
|
||||
|
||||
const platform = user.platform?.toLowerCase()
|
||||
const location = user.location?.toLowerCase()
|
||||
const status = user.status?.toLowerCase()
|
||||
|
||||
const state = 'state' in user ? user.state : undefined
|
||||
if (state !== undefined) {
|
||||
const stateLower = state?.toLowerCase()
|
||||
if (stateLower === 'offline') {
|
||||
if (status && status !== 'offline') return false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (platform === 'web' && status && status !== 'offline') return false
|
||||
|
||||
if (!location) return true
|
||||
if (location.startsWith('wrld_') || location.startsWith('traveling')) return false
|
||||
if (location === 'private') return false
|
||||
|
||||
return location === 'offline' || location === ''
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS class for the small status dot.
|
||||
*/
|
||||
export function getStatusDotClass(user: User | LimitedUserFriend | null | undefined): string {
|
||||
if (!user) return 'bg-gray-500'
|
||||
|
||||
const statusLower = user.status?.toLowerCase()
|
||||
const platformLower = user.platform?.toLowerCase()
|
||||
const offline = isUserOffline(user)
|
||||
|
||||
if (offline) return 'bg-gray-500'
|
||||
|
||||
if (platformLower === 'web') {
|
||||
switch (statusLower) {
|
||||
case 'active':
|
||||
return 'bg-transparent ring-2 ring-emerald-500 ring-inset'
|
||||
case 'join me':
|
||||
return 'bg-transparent ring-2 ring-sky-500 ring-inset'
|
||||
case 'ask me':
|
||||
return 'bg-transparent ring-2 ring-amber-500 ring-inset'
|
||||
case 'busy':
|
||||
return 'bg-transparent ring-2 ring-red-500 ring-inset'
|
||||
default:
|
||||
return 'bg-transparent ring-2 ring-gray-400 ring-inset'
|
||||
}
|
||||
}
|
||||
|
||||
switch (statusLower) {
|
||||
case 'active':
|
||||
return 'bg-emerald-500'
|
||||
case 'join me':
|
||||
return 'bg-sky-500'
|
||||
case 'ask me':
|
||||
return 'bg-amber-500'
|
||||
case 'busy':
|
||||
return 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS class for the status badge/chip.
|
||||
*/
|
||||
export function getStatusBadgeColor(user: User | LimitedUserFriend | null | undefined): string {
|
||||
if (!user) return 'bg-gray-500'
|
||||
|
||||
const statusLower = user.status?.toLowerCase()
|
||||
const platformLower = user.platform?.toLowerCase()
|
||||
const offline = isUserOffline(user)
|
||||
|
||||
if (offline) return 'bg-gray-500'
|
||||
|
||||
if (platformLower === 'web') {
|
||||
switch (statusLower) {
|
||||
case 'active':
|
||||
return 'bg-transparent ring-2 ring-emerald-500 ring-inset'
|
||||
case 'join me':
|
||||
return 'bg-transparent ring-2 ring-sky-500 ring-inset'
|
||||
case 'ask me':
|
||||
return 'bg-transparent ring-2 ring-amber-500 ring-inset'
|
||||
case 'busy':
|
||||
return 'bg-transparent ring-2 ring-red-500 ring-inset'
|
||||
default:
|
||||
return 'bg-transparent ring-2 ring-gray-400 ring-inset'
|
||||
}
|
||||
}
|
||||
|
||||
switch (statusLower) {
|
||||
case 'active':
|
||||
return 'bg-emerald-500'
|
||||
case 'join me':
|
||||
return 'bg-sky-500'
|
||||
case 'ask me':
|
||||
return 'bg-amber-500'
|
||||
case 'busy':
|
||||
return 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
}
|
||||
13
src/main.tsx
Normal file
13
src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
import "./devtools-animations.css";
|
||||
import "./utils/logger";
|
||||
import "./i18n/config";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
420
src/pages/Avatars.tsx
Normal file
420
src/pages/Avatars.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { avatarsStore } from "@/stores/avatars-store";
|
||||
import type { LimitedAvatar } from "@/types/bindings";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { LoginRequired } from "@/components/LoginRequired";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CachedImage } from "@/components/CachedImage";
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) {
|
||||
return "Unknown";
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return "Unknown";
|
||||
}
|
||||
return dateFormatter.format(parsed);
|
||||
}
|
||||
|
||||
function platformLabel(platform?: string | null): string | null {
|
||||
if (!platform) {
|
||||
return null;
|
||||
}
|
||||
const normalized = platform.toLowerCase();
|
||||
switch (normalized) {
|
||||
case "standalonewindows":
|
||||
case "standalonewindows64":
|
||||
return "PC";
|
||||
case "android":
|
||||
return "Quest";
|
||||
case "ios":
|
||||
return "iOS";
|
||||
default:
|
||||
return platform;
|
||||
}
|
||||
}
|
||||
|
||||
function extractPlatforms(avatar: LimitedAvatar): string[] {
|
||||
const platforms = new Set<string>();
|
||||
avatar.unityPackages?.forEach((pkg) => {
|
||||
const label = platformLabel(pkg.platform);
|
||||
if (label) {
|
||||
platforms.add(label);
|
||||
}
|
||||
});
|
||||
return Array.from(platforms);
|
||||
}
|
||||
|
||||
function performanceEntries(avatar: LimitedAvatar): string[] {
|
||||
const entries: string[] = [];
|
||||
const performance = avatar.performance;
|
||||
if (!performance) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
const mapping: Array<[keyof typeof performance, string]> = [
|
||||
["standalonewindows", "PC"],
|
||||
["android", "Quest"],
|
||||
["ios", "iOS"],
|
||||
];
|
||||
|
||||
for (const [key, label] of mapping) {
|
||||
const value = performance[key];
|
||||
if (value) {
|
||||
entries.push(`${label}: ${value}`);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function cleanTag(tag: string): string {
|
||||
if (tag.startsWith("author_tag_")) {
|
||||
return tag.replace("author_tag_", "");
|
||||
}
|
||||
if (tag.startsWith("system_")) {
|
||||
return tag.replace("system_", "");
|
||||
}
|
||||
if (tag.startsWith("admin_")) {
|
||||
return tag.replace("admin_", "");
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
export function Avatars() {
|
||||
const { user } = useAuth();
|
||||
const [avatars, setAvatars] = useState<LimitedAvatar[] | null>(
|
||||
avatarsStore.getSnapshot()
|
||||
);
|
||||
const [loading, setLoading] = useState(!avatars);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const handleUpdate = (value: LimitedAvatar[] | null) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setAvatars(value);
|
||||
setLoading(false);
|
||||
if (value) {
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = avatarsStore.subscribe(handleUpdate);
|
||||
|
||||
const snapshot = avatarsStore.getSnapshot();
|
||||
setAvatars(snapshot);
|
||||
|
||||
if (!user) {
|
||||
setAvatars(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
avatarsStore.ensure().catch((err) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to load avatars";
|
||||
setError(message);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await avatarsStore.refresh();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to refresh avatars";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!user) {
|
||||
return <LoginRequired />;
|
||||
}
|
||||
|
||||
const hasAvatars = (avatars?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full flex flex-col gap-8">
|
||||
<div className="w-full space-y-6">
|
||||
<div className="flex flex-col-reverse gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t("page.avatars.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t("page.avatars.description")}
|
||||
</p>
|
||||
{hasAvatars && !loading && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{avatars && avatars.length === 1
|
||||
? t("page.avatars.showingSingle", { count: avatars.length })
|
||||
: t("page.avatars.showingMultiple", {
|
||||
count: avatars?.length ?? 0,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading
|
||||
? t("page.avatars.refresh.loading")
|
||||
: t("page.avatars.refresh.label")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("page.avatars.error.title")}</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button size="sm" onClick={handleRefresh} disabled={loading}>
|
||||
{t("page.avatars.error.tryAgain")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3 w-full">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Card key={index} className="overflow-hidden">
|
||||
<CardContent>{t("page.avatars.refresh.loading")}</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !hasAvatars && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("page.avatars.noItemsTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("page.avatars.noItemsDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && hasAvatars && (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{avatars!.map((avatar) => {
|
||||
const platforms = extractPlatforms(avatar);
|
||||
const performance = performanceEntries(avatar);
|
||||
const previewUrl =
|
||||
avatar.imageUrl ?? avatar.thumbnailImageUrl ?? undefined;
|
||||
const updatedLabel = formatDate(
|
||||
avatar.updatedAt ?? avatar.createdAt
|
||||
);
|
||||
const isFeatured = Boolean(avatar.featured);
|
||||
const styles = avatar.styles ?? null;
|
||||
|
||||
return (
|
||||
<Card key={avatar.id} className="overflow-hidden flex flex-col">
|
||||
<div className="relative aspect-video bg-muted">
|
||||
{previewUrl ? (
|
||||
<CachedImage
|
||||
src={previewUrl}
|
||||
alt={avatar.name}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{t("page.avatars.noPreview")}
|
||||
</div>
|
||||
)}
|
||||
{avatar.releaseStatus && (
|
||||
<Badge
|
||||
className="absolute left-4 top-4 uppercase"
|
||||
variant="secondary"
|
||||
>
|
||||
{t(`page.avatars.status.${avatar.releaseStatus}`, {
|
||||
defaultValue: avatar.releaseStatus,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
{isFeatured && (
|
||||
<Badge
|
||||
className="absolute right-4 top-4 uppercase"
|
||||
variant="default"
|
||||
>
|
||||
Featured
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardHeader className="flex-0 space-y-2">
|
||||
<CardTitle className="leading-tight">
|
||||
{avatar.name}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{avatar.authorName
|
||||
? t("page.avatars.byAuthor", {
|
||||
author: avatar.authorName,
|
||||
})
|
||||
: t("page.avatars.authorUnknown")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
{avatar.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||
{avatar.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{platforms.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{platforms.map((platform) => (
|
||||
<Badge
|
||||
key={`${avatar.id}-${platform}`}
|
||||
variant="outline"
|
||||
>
|
||||
{platform}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{performance.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("page.avatars.labels.performance")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{performance.map((entry) => (
|
||||
<Badge
|
||||
key={`${avatar.id}-${entry}`}
|
||||
variant="secondary"
|
||||
>
|
||||
{entry}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("page.avatars.labels.release")}
|
||||
</p>
|
||||
<p className="font-medium capitalize">
|
||||
{avatar.releaseStatus
|
||||
? t(`page.avatars.status.${avatar.releaseStatus}`, {
|
||||
defaultValue: avatar.releaseStatus,
|
||||
})
|
||||
: t("page.avatars.labels.unknown")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("page.avatars.labels.updated")}
|
||||
</p>
|
||||
<p className="font-medium">{updatedLabel}</p>
|
||||
</div>
|
||||
{avatar.version !== undefined &&
|
||||
avatar.version !== null && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("page.avatars.labels.version")}
|
||||
</p>
|
||||
<p className="font-medium">{avatar.version}</p>
|
||||
</div>
|
||||
)}
|
||||
{styles && (styles.primary || styles.secondary) && (
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("page.avatars.labels.style")}
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{[styles.primary, styles.secondary]
|
||||
.filter((value): value is string =>
|
||||
Boolean(value)
|
||||
)
|
||||
.join(" / ") || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{avatar.tags && avatar.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
{avatar.tags.slice(0, 6).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-muted px-2 py-1"
|
||||
>
|
||||
#{cleanTag(tag)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("page.avatars.labels.createdPrefix", {
|
||||
date: formatDate(avatar.createdAt),
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
658
src/pages/DatabaseStudio.tsx
Normal file
658
src/pages/DatabaseStudio.tsx
Normal file
@@ -0,0 +1,658 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { DatabaseService } from "@/services/database";
|
||||
import type { TableInfo, ColumnInfo, QueryResult } from "@/types/bindings";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Database,
|
||||
Play,
|
||||
RefreshCw,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Key,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function DatabaseStudio() {
|
||||
const { t } = useTranslation();
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
||||
const [schema, setSchema] = useState<ColumnInfo[]>([]);
|
||||
const [tableData, setTableData] = useState<QueryResult | null>(null);
|
||||
const [rowCount, setRowCount] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [customQuery, setCustomQuery] = useState("");
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 50;
|
||||
const [editingCell, setEditingCell] = useState<{
|
||||
rowIdx: number;
|
||||
col: string;
|
||||
} | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [editedRows, setEditedRows] = useState<
|
||||
Map<number, Record<string, string>>
|
||||
>(new Map());
|
||||
const [schemaExpanded, setSchemaExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTable) {
|
||||
loadTableData(selectedTable);
|
||||
}
|
||||
}, [selectedTable, page]);
|
||||
|
||||
const loadTables = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const tableList = await DatabaseService.listTables();
|
||||
setTables(tableList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load tables", error);
|
||||
toast.error(
|
||||
t("component.developerTools.databaseStudio.toasts.loadTablesFailed")
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTableData = async (tableName: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [schemaData, data, count] = await Promise.all([
|
||||
DatabaseService.getTableSchema(tableName),
|
||||
DatabaseService.getTableData(tableName, pageSize, page * pageSize),
|
||||
DatabaseService.getTableCount(tableName),
|
||||
]);
|
||||
|
||||
setSchema(schemaData);
|
||||
setTableData(data);
|
||||
setRowCount(count);
|
||||
} catch (error) {
|
||||
console.error("Failed to load table data", error);
|
||||
toast.error(
|
||||
t("component.developerTools.databaseStudio.toasts.loadTableDataFailed")
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const executeCustomQuery = async () => {
|
||||
if (!customQuery.trim()) {
|
||||
toast.error(
|
||||
t("component.developerTools.databaseStudio.toasts.enterQuery")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await DatabaseService.executeQuery(customQuery);
|
||||
setQueryResult(result);
|
||||
toast.success(
|
||||
t("component.developerTools.databaseStudio.toasts.queryExecuted")
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Query execution failed", error);
|
||||
toast.error(
|
||||
t("component.developerTools.databaseStudio.toasts.queryFailed", {
|
||||
message: String(error),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCellEdit = (
|
||||
rowIdx: number,
|
||||
col: string,
|
||||
currentValue: string
|
||||
) => {
|
||||
setEditingCell({ rowIdx, col });
|
||||
setEditValue(currentValue === "NULL" ? "" : currentValue);
|
||||
};
|
||||
|
||||
const handleSaveCell = (rowIdx: number, col: string) => {
|
||||
if (!tableData || !selectedTable) return;
|
||||
|
||||
const row = tableData.rows[rowIdx];
|
||||
const newEditedRows = new Map(editedRows);
|
||||
const editedRow = newEditedRows.get(rowIdx) || { ...row };
|
||||
editedRow[col] = editValue;
|
||||
newEditedRows.set(rowIdx, editedRow as Record<string, string>);
|
||||
setEditedRows(newEditedRows);
|
||||
|
||||
// Update local data
|
||||
const newRows = [...tableData.rows];
|
||||
newRows[rowIdx] = { ...row, [col]: editValue };
|
||||
setTableData({ ...tableData, rows: newRows });
|
||||
|
||||
setEditingCell(null);
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingCell(null);
|
||||
setEditValue("");
|
||||
};
|
||||
|
||||
const handleSaveRow = async (rowIdx: number) => {
|
||||
if (!selectedTable || !tableData) return;
|
||||
|
||||
const editedRow = editedRows.get(rowIdx);
|
||||
if (!editedRow) return;
|
||||
|
||||
const primaryKey = schema.find((col) => col.pk === 1);
|
||||
if (!primaryKey) {
|
||||
toast.error(
|
||||
t("component.developerTools.databaseStudio.toasts.cannotUpdateNoPK")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pkValue = editedRow[primaryKey.name];
|
||||
const setClauses = Object.entries(editedRow)
|
||||
.filter(([key]) => key !== primaryKey.name)
|
||||
.map(([key, value]) => `${key} = '${value.replace(/'/g, "''")}'`)
|
||||
.join(", ");
|
||||
|
||||
const updateQuery = `UPDATE ${selectedTable} SET ${setClauses} WHERE ${
|
||||
primaryKey.name
|
||||
} = '${pkValue.replace(/'/g, "''")}'`;
|
||||
|
||||
try {
|
||||
await DatabaseService.executeQuery(updateQuery);
|
||||
toast.success(
|
||||
t("component.developerTools.databaseStudio.toasts.rowUpdated")
|
||||
);
|
||||
const newEditedRows = new Map(editedRows);
|
||||
newEditedRows.delete(rowIdx);
|
||||
setEditedRows(newEditedRows);
|
||||
await loadTableData(selectedTable);
|
||||
} catch (error) {
|
||||
console.error("Failed to update row", error);
|
||||
toast.error(
|
||||
t("component.developerTools.databaseStudio.toasts.updateFailed", {
|
||||
message: String(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRow = async (rowIdx: number) => {
|
||||
if (!selectedTable || !tableData) return;
|
||||
|
||||
const row = tableData.rows[rowIdx];
|
||||
const primaryKey = schema.find((col) => col.pk === 1);
|
||||
|
||||
if (!primaryKey) {
|
||||
toast.error(
|
||||
t("component.developerTools.databaseStudio.toasts.cannotDeleteNoPK")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pkValue = row[primaryKey.name];
|
||||
const confirmed = window.confirm(
|
||||
t("component.developerTools.databaseStudio.confirm.deleteRow", {
|
||||
column: primaryKey.name,
|
||||
value: String(pkValue),
|
||||
})
|
||||
);
|
||||
|
||||
if (!confirmed || !pkValue) return;
|
||||
|
||||
const deleteQuery = `DELETE FROM ${selectedTable} WHERE ${
|
||||
primaryKey.name
|
||||
} = '${pkValue.replace(/'/g, "''")}'`;
|
||||
|
||||
try {
|
||||
await DatabaseService.executeQuery(deleteQuery);
|
||||
toast.success(
|
||||
t("component.developerTools.databaseStudio.toasts.rowDeleted")
|
||||
);
|
||||
await loadTableData(selectedTable);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete row", error);
|
||||
toast.error(
|
||||
t("component.developerTools.databaseStudio.toasts.deleteFailed", {
|
||||
message: String(error),
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTable = (result: QueryResult, editable = false) => {
|
||||
if (!result || result.columns.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("component.developerTools.databaseStudio.browse.data.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasEdits = (rowIdx: number) => editedRows.has(rowIdx);
|
||||
|
||||
return (
|
||||
<div className="border rounded-md overflow-x-auto">
|
||||
<Table className="min-w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{editable && (
|
||||
<TableHead className="w-24 whitespace-nowrap">
|
||||
{t(
|
||||
"component.developerTools.databaseStudio.browse.table.actions"
|
||||
)}
|
||||
</TableHead>
|
||||
)}
|
||||
{result.columns.map((col) => (
|
||||
<TableHead
|
||||
key={col}
|
||||
className="font-mono text-xs font-semibold whitespace-nowrap"
|
||||
>
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={result.columns.length + (editable ? 1 : 0)}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"component.developerTools.databaseStudio.browse.table.noRows"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
result.rows.map((row, idx) => (
|
||||
<TableRow
|
||||
key={idx}
|
||||
className={
|
||||
hasEdits(idx) ? "bg-yellow-50 dark:bg-yellow-950/20" : ""
|
||||
}
|
||||
>
|
||||
{editable && (
|
||||
<TableCell className="space-x-1">
|
||||
{hasEdits(idx) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => handleSaveRow(idx)}
|
||||
title={t(
|
||||
"component.developerTools.databaseStudio.browse.table.saveTitle"
|
||||
)}
|
||||
>
|
||||
<Save className="h-3 w-3 text-green-600" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => handleDeleteRow(idx)}
|
||||
title={t(
|
||||
"component.developerTools.databaseStudio.browse.table.deleteTitle"
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-red-600" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
{result.columns.map((col) => {
|
||||
const value = row[col];
|
||||
const displayValue =
|
||||
value === null || value === undefined
|
||||
? "NULL"
|
||||
: typeof value === "object"
|
||||
? JSON.stringify(value)
|
||||
: String(value);
|
||||
|
||||
const isEditing =
|
||||
editingCell?.rowIdx === idx && editingCell?.col === col;
|
||||
|
||||
return (
|
||||
<TableCell key={col} className="font-mono text-xs p-0">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<Input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="h-7 text-xs font-mono"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSaveCell(idx, col);
|
||||
if (e.key === "Escape") handleCancelEdit();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => handleSaveCell(idx, col)}
|
||||
>
|
||||
<Save className="h-3 w-3 text-green-600" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
<X className="h-3 w-3 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`p-2 truncate max-w-md ${
|
||||
editable ? "cursor-pointer hover:bg-muted/50" : ""
|
||||
}`}
|
||||
title={displayValue}
|
||||
onClick={() =>
|
||||
editable && handleCellEdit(idx, col, displayValue)
|
||||
}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(rowCount / pageSize);
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-background">
|
||||
{/* Sidebar - Table List */}
|
||||
<div className="w-64 border-r flex flex-col">
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Database className="h-5 w-5 text-primary" />
|
||||
<h2 className="font-semibold">
|
||||
{t("component.developerTools.databaseStudio.sidebar.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={loadTables}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("component.developerTools.databaseStudio.sidebar.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
{tables.map((table) => (
|
||||
<Button
|
||||
key={table.name}
|
||||
variant={selectedTable === table.name ? "secondary" : "ghost"}
|
||||
className="w-full justify-start mb-1 font-mono text-xs"
|
||||
onClick={() => {
|
||||
setSelectedTable(table.name);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 mr-2" />
|
||||
{table.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Tabs defaultValue="browse" className="flex-1 flex flex-col">
|
||||
<div className="border-b px-4 py-3">
|
||||
<TabsList className="bg-transparent p-0 h-auto gap-3 rounded-none shadow-none">
|
||||
<TabsTrigger value="browse">
|
||||
{t("component.developerTools.databaseStudio.tabs.browse")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="query">
|
||||
{t("component.developerTools.databaseStudio.tabs.query")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="browse"
|
||||
className="flex-1 overflow-hidden m-0 p-6"
|
||||
>
|
||||
{!selectedTable ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t(
|
||||
"component.developerTools.databaseStudio.browse.noTableSelected.title"
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"component.developerTools.databaseStudio.browse.noTableSelected.description"
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex flex-col h-full gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-mono flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
{selectedTable}
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{rowCount} rows
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>Table schema and data</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(
|
||||
"component.developerTools.databaseStudio.browse.schema.title"
|
||||
)}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-1"
|
||||
onClick={() => setSchemaExpanded((prev) => !prev)}
|
||||
>
|
||||
{schemaExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
{schemaExpanded
|
||||
? t(
|
||||
"component.developerTools.databaseStudio.browse.schema.hide"
|
||||
)
|
||||
: t(
|
||||
"component.developerTools.databaseStudio.browse.schema.show"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{!schemaExpanded && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{t(
|
||||
"component.developerTools.databaseStudio.browse.schema.hidden",
|
||||
{ count: schema.length }
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{schemaExpanded && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{schema.map((col) => (
|
||||
<Badge
|
||||
key={col.name}
|
||||
variant="outline"
|
||||
className="font-mono"
|
||||
>
|
||||
{col.pk === 1 && <Key className="h-3 w-3 mr-1" />}
|
||||
{col.name}: {col.type}
|
||||
{col.notnull === 1 && " NOT NULL"}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex-1 overflow-hidden flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
{t(
|
||||
"component.developerTools.databaseStudio.browse.data.title"
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setPage(Math.max(0, page - 1))}
|
||||
disabled={page === 0 || loading}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t(
|
||||
"component.developerTools.databaseStudio.browse.data.previous"
|
||||
)}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"component.developerTools.databaseStudio.browse.data.pageInfo",
|
||||
{ page: page + 1, total: totalPages }
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setPage(Math.min(totalPages - 1, page + 1))
|
||||
}
|
||||
disabled={page >= totalPages - 1 || loading}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t(
|
||||
"component.developerTools.databaseStudio.browse.data.next"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-auto p-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
{t(
|
||||
"component.developerTools.databaseStudio.browse.data.loading"
|
||||
)}
|
||||
</div>
|
||||
) : tableData ? (
|
||||
renderTable(tableData, true)
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="query" className="flex-1 overflow-hidden m-0 p-6">
|
||||
<div className="flex flex-col h-full gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Custom SQL Query</CardTitle>
|
||||
<CardDescription>
|
||||
Execute custom SQL queries against the database (read-only
|
||||
recommended)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
value={customQuery}
|
||||
onChange={(e) => setCustomQuery(e.target.value)}
|
||||
placeholder="SELECT * FROM accounts LIMIT 10"
|
||||
className="font-mono text-sm min-h-[150px]"
|
||||
/>
|
||||
<Button onClick={executeCustomQuery} disabled={loading}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Execute Query
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{queryResult && (
|
||||
<Card className="flex-1 overflow-hidden flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Query Results</CardTitle>
|
||||
{queryResult.rows_affected !== null && (
|
||||
<CardDescription>
|
||||
Rows affected: {queryResult.rows_affected}
|
||||
</CardDescription>
|
||||
)}
|
||||
{queryResult.rows.length > 0 && (
|
||||
<CardDescription>
|
||||
{queryResult.rows.length} rows returned
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-auto p-4">
|
||||
{renderTable(queryResult)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/pages/Home.tsx
Normal file
32
src/pages/Home.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { LoginRequired } from "@/components/LoginRequired";
|
||||
|
||||
export function Home() {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return <LoginRequired />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full flex flex-col gap-8">
|
||||
<div className="w-full">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Home</h1>
|
||||
<p className="text-muted-foreground mt-2">Coming soon</p>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Coming Soon
|
||||
</CardTitle>
|
||||
<CardDescription>Coming soon</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent></CardContent>
|
||||
</Card> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
src/pages/Login.tsx
Normal file
156
src/pages/Login.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { AccountMenu } from "@/components/AccountMenu";
|
||||
import { AlertCircle, LogIn } from "lucide-react";
|
||||
import { VRChatError } from "@/types/errors";
|
||||
|
||||
export function Login() {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await login(email, password);
|
||||
|
||||
if (result === "success") {
|
||||
navigate("/");
|
||||
} else if (result === "needs_2fa") {
|
||||
navigate("/verify-2fa");
|
||||
} else {
|
||||
setError("Login failed. Please check your credentials and try again.");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof VRChatError) {
|
||||
setError(err.getUserMessage());
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4 relative">
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<AccountMenu />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
{/* Logo Area */}
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">VRC Circle</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("page.login.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardHeader className="space-y-1 pb-4">
|
||||
<CardTitle className="text-xl">
|
||||
{t("page.login.welcomeTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("page.login.welcomeDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span className="flex-1">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email or Username</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("page.login.placeholderEmail")}
|
||||
required
|
||||
disabled={loading}
|
||||
autoComplete="username"
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t("page.login.title")}</Label>
|
||||
<Input
|
||||
ref={passwordInputRef}
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("page.login.placeholderPassword")}
|
||||
required
|
||||
disabled={loading}
|
||||
autoComplete="current-password"
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col space-y-4">
|
||||
<Button type="submit" className="w-full h-10" disabled={loading}>
|
||||
{loading ? (
|
||||
t("page.login.signingIn")
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
{t("page.login.signInButton")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Footer Notice */}
|
||||
<div className="text-center space-y-1">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-xs h-auto p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
{t("page.login.skip")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
src/pages/Logs.tsx
Normal file
372
src/pages/Logs.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { FileText, Download, Trash2, RefreshCw, Search, X } from "lucide-react";
|
||||
import { logger, LogEntry as FrontendLogEntry, LogLevel } from "@/utils/logger";
|
||||
import { LogsService, CombinedLogEntry } from "@/services/logs";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type LogSource = "all" | "frontend" | "backend";
|
||||
type LogLevelFilter = "all" | LogLevel;
|
||||
|
||||
export function Logs() {
|
||||
const { t } = useTranslation();
|
||||
const [frontendLogs, setFrontendLogs] = useState<FrontendLogEntry[]>([]);
|
||||
const [backendLogs, setBackendLogs] = useState<CombinedLogEntry[]>([]);
|
||||
const [sourceFilter, setSourceFilter] = useState<LogSource>("all");
|
||||
const [levelFilter, setLevelFilter] = useState<LogLevelFilter>("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Load frontend logs from logger
|
||||
setFrontendLogs(logger.getLogs());
|
||||
|
||||
// Subscribe to new frontend logs
|
||||
const unsubscribe = logger.subscribe((entry) => {
|
||||
setFrontendLogs((prev) => [...prev, entry]);
|
||||
});
|
||||
|
||||
// Load backend logs
|
||||
loadBackendLogs();
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
loadBackendLogs();
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh]);
|
||||
|
||||
const loadBackendLogs = async () => {
|
||||
try {
|
||||
const logs = await LogsService.getBackendLogs();
|
||||
setBackendLogs(logs);
|
||||
} catch (error) {
|
||||
console.error("Failed to load backend logs:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await loadBackendLogs();
|
||||
setFrontendLogs(logger.getLogs());
|
||||
toast.success(t("component.developerTools.logger.toasts.refreshed"));
|
||||
} catch (error) {
|
||||
toast.error(t("component.developerTools.logger.toasts.refreshFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!confirm(t("component.developerTools.logger.confirm.clearAll"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
logger.clearLogs();
|
||||
await LogsService.clearBackendLogs();
|
||||
setFrontendLogs([]);
|
||||
setBackendLogs([]);
|
||||
toast.success(t("component.developerTools.logger.toasts.cleared"));
|
||||
} catch (error) {
|
||||
toast.error(t("component.developerTools.logger.toasts.clearFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const allLogs = [...frontendLogs, ...backendLogs].sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
const exportData = JSON.stringify(allLogs, null, 2);
|
||||
const blob = new Blob([exportData], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `vrc-circle-logs-${new Date().toISOString()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(t("component.developerTools.logger.toasts.exported"));
|
||||
} catch (error) {
|
||||
toast.error(t("component.developerTools.logger.toasts.exportFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const combinedLogs = useMemo(() => {
|
||||
const all: CombinedLogEntry[] = [...frontendLogs, ...backendLogs];
|
||||
|
||||
// Apply filters
|
||||
let filtered = all;
|
||||
|
||||
if (sourceFilter !== "all") {
|
||||
filtered = filtered.filter((log) => log.source === sourceFilter);
|
||||
}
|
||||
|
||||
if (levelFilter !== "all") {
|
||||
filtered = filtered.filter(
|
||||
(log) => log.level.toLowerCase() === levelFilter
|
||||
);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(log) =>
|
||||
log.message.toLowerCase().includes(query) ||
|
||||
log.module.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
return filtered.sort(
|
||||
(a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
}, [frontendLogs, backendLogs, sourceFilter, levelFilter, searchQuery]);
|
||||
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level.toLowerCase()) {
|
||||
case "error":
|
||||
return "bg-red-500/10 text-red-500 border-red-500/20";
|
||||
case "warn":
|
||||
return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20";
|
||||
case "info":
|
||||
return "bg-blue-500/10 text-blue-500 border-blue-500/20";
|
||||
case "debug":
|
||||
return "bg-gray-500/10 text-gray-500 border-gray-500/20";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceColor = (source: string) => {
|
||||
return source === "frontend"
|
||||
? "bg-purple-500/10 text-purple-500 border-purple-500/20"
|
||||
: "bg-orange-500/10 text-orange-500 border-orange-500/20";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-background">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 border-r flex flex-col">
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
<h2 className="font-semibold">
|
||||
{t("component.developerTools.logger.title")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("component.developerTools.logger.sidebar.refresh")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${autoRefresh ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("component.developerTools.logger.sidebar.autoRefresh")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{t("component.developerTools.logger.sidebar.export")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleClearAll}
|
||||
disabled={loading}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t("component.developerTools.logger.sidebar.clearAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Search */}
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-2">
|
||||
{t("component.developerTools.logger.sidebar.searchLabel")}
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"component.developerTools.logger.sidebar.searchPlaceholder"
|
||||
)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-7 pr-7 h-8 text-xs"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Filter */}
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-2">
|
||||
{t("component.developerTools.logger.sidebar.source")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(["all", "frontend", "backend"] as LogSource[]).map(
|
||||
(source) => (
|
||||
<Badge
|
||||
key={source}
|
||||
variant={sourceFilter === source ? "default" : "outline"}
|
||||
className="cursor-pointer capitalize justify-center text-xs py-1"
|
||||
onClick={() => setSourceFilter(source)}
|
||||
>
|
||||
{source}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level Filter */}
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-2">
|
||||
{t("component.developerTools.logger.sidebar.level")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(
|
||||
["all", "debug", "info", "warn", "error"] as LogLevelFilter[]
|
||||
).map((level) => (
|
||||
<Badge
|
||||
key={level}
|
||||
variant={levelFilter === level ? "default" : "outline"}
|
||||
className="cursor-pointer capitalize justify-center text-xs py-1"
|
||||
onClick={() => setLevelFilter(level)}
|
||||
>
|
||||
{level}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground pt-2 border-t">
|
||||
{t("component.developerTools.logger.sidebar.counts", {
|
||||
filtered: combinedLogs.length,
|
||||
total: frontendLogs.length + backendLogs.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Main Content (Log Entries) */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="p-4 border-b">
|
||||
<h3 className="font-semibold">
|
||||
{t("component.developerTools.logger.main.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("component.developerTools.logger.main.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-1">
|
||||
{combinedLogs.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
{t("component.developerTools.logger.main.empty")}
|
||||
</div>
|
||||
) : (
|
||||
combinedLogs.map((log, index) => (
|
||||
<div
|
||||
key={`${log.source}-${log.timestamp}-${index}`}
|
||||
className="px-2 py-1 rounded-sm hover:bg-accent/50 transition-colors flex items-center gap-1.5 text-xs overflow-hidden"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${getSourceColor(
|
||||
log.source
|
||||
)} text-[10px] py-0 px-1 h-4 flex-shrink-0`}
|
||||
>
|
||||
{log.source}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${getLevelColor(
|
||||
log.level
|
||||
)} text-[10px] py-0 px-1 h-4 flex-shrink-0`}
|
||||
>
|
||||
{log.level.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground text-[10px] py-0 px-1 h-4 flex-shrink-0"
|
||||
>
|
||||
{log.module}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground flex-shrink-0">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="font-mono truncate flex-1 min-w-0">
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
730
src/pages/Profile.tsx
Normal file
730
src/pages/Profile.tsx
Normal file
@@ -0,0 +1,730 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import {
|
||||
cn,
|
||||
isUserOffline,
|
||||
getStatusDotClass,
|
||||
getStatusBadgeColor,
|
||||
} from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Award, FileText, Info, Link as LinkIcon } from "lucide-react";
|
||||
import { UserAvatar } from "@/components/UserAvatar";
|
||||
import { CachedImage } from "@/components/CachedImage";
|
||||
import { useCachedImage } from "@/hooks/useCachedImage";
|
||||
import { LoginRequired } from "@/components/LoginRequired";
|
||||
import { VRChatService } from "@/services/vrchat";
|
||||
import { userStore } from "@/stores";
|
||||
import type {
|
||||
User,
|
||||
LimitedUserFriend,
|
||||
Badge as BadgeType,
|
||||
} from "@/types/bindings";
|
||||
|
||||
// TODO: Improve these to less hacky way, might require store refactoring
|
||||
// Strong type guards for runtime narrowing
|
||||
function isFullUser(obj: unknown): obj is User {
|
||||
return (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
// "username" is present on full User but not on LimitedUserFriend
|
||||
"username" in (obj as Record<string, unknown>) &&
|
||||
typeof (obj as any).username === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isLimitedUserFriend(obj: unknown): obj is LimitedUserFriend {
|
||||
return (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
"displayName" in (obj as Record<string, unknown>) &&
|
||||
!("username" in (obj as Record<string, unknown>))
|
||||
);
|
||||
}
|
||||
|
||||
export function Profile() {
|
||||
const { user: currentUser, loading: authLoading } = useAuth();
|
||||
const { userId } = useParams<{ userId?: string }>();
|
||||
const [activeTab, setActiveTab] = useState("about");
|
||||
|
||||
const viewingSelf = !userId || userId === currentUser?.id;
|
||||
const targetUserId = viewingSelf ? currentUser?.id ?? null : userId ?? null;
|
||||
|
||||
const [profile, setProfile] = useState<User | LimitedUserFriend | null>(
|
||||
viewingSelf ? currentUser ?? null : null
|
||||
);
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab("about");
|
||||
}, [targetUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
if (viewingSelf) {
|
||||
if (currentUser) {
|
||||
setProfile(currentUser);
|
||||
setProfileError(null);
|
||||
} else if (!authLoading) {
|
||||
setProfile(null);
|
||||
setProfileError("Profile not available.");
|
||||
}
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
if (!targetUserId) {
|
||||
setProfile(null);
|
||||
setProfileError("User not found.");
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
setProfileError(null);
|
||||
|
||||
const snapshot = userStore.getFriendsSnapshot();
|
||||
|
||||
// Check if we have friend data to show immediately
|
||||
if (snapshot) {
|
||||
const cachedFriend = snapshot.find(
|
||||
(friend) => friend.id === targetUserId
|
||||
);
|
||||
if (cachedFriend) {
|
||||
setProfile(cachedFriend);
|
||||
}
|
||||
} else {
|
||||
userStore.ensureFriends().catch(() => undefined);
|
||||
}
|
||||
|
||||
// Get full user data in the background (from cache if available)
|
||||
VRChatService.getUserById(targetUserId)
|
||||
.then((result) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProfile(result);
|
||||
setProfileError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
console.error("Failed to load profile", error);
|
||||
|
||||
if (!profile) {
|
||||
setProfileError("User not found.");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [viewingSelf, targetUserId, currentUser, authLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewingSelf || !currentUser?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
VRChatService.getCurrentUser()
|
||||
.then((fresh) => {
|
||||
if (!cancelled) {
|
||||
setProfile(fresh);
|
||||
setProfileError(null);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to load current user profile", error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [viewingSelf, currentUser?.id]);
|
||||
|
||||
// TODO: Refactor to common utility
|
||||
const getLanguageName = (tag: string): string | null => {
|
||||
if (!tag.startsWith("language_")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const langCode = tag.replace("language_", "").toLowerCase();
|
||||
|
||||
const codeToName: Record<string, string> = {
|
||||
eng: "English",
|
||||
spa: "Spanish",
|
||||
fra: "French",
|
||||
deu: "German",
|
||||
ita: "Italian",
|
||||
por: "Portuguese",
|
||||
jpn: "Japanese",
|
||||
zho: "Chinese",
|
||||
kor: "Korean",
|
||||
rus: "Russian",
|
||||
ukr: "Ukrainian",
|
||||
pol: "Polish",
|
||||
tur: "Turkish",
|
||||
ara: "Arabic",
|
||||
hin: "Hindi",
|
||||
tha: "Thai",
|
||||
vie: "Vietnamese",
|
||||
nld: "Dutch",
|
||||
swe: "Swedish",
|
||||
nor: "Norwegian",
|
||||
dan: "Danish",
|
||||
fin: "Finnish",
|
||||
ell: "Greek",
|
||||
ces: "Czech",
|
||||
hun: "Hungarian",
|
||||
ron: "Romanian",
|
||||
bul: "Bulgarian",
|
||||
hrv: "Croatian",
|
||||
srp: "Serbian",
|
||||
slk: "Slovak",
|
||||
slv: "Slovenian",
|
||||
};
|
||||
|
||||
return codeToName[langCode] || null;
|
||||
};
|
||||
|
||||
const getLinkLabel = (link: string): string => {
|
||||
try {
|
||||
return getLinkLabel(link);
|
||||
} catch {
|
||||
return link;
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: Refactor to common utility
|
||||
// TODO: Handle nuisance cases, check other places too
|
||||
const getHighestTrustRank = (
|
||||
tags: string[]
|
||||
): { name: string; color: string } | null => {
|
||||
// Order: Veteran (Trusted User) > Trusted (Known User) > Known (User) > New (New User) > Visitor
|
||||
if (tags.includes("system_trust_veteran")) {
|
||||
return {
|
||||
name: "Trusted",
|
||||
color: "bg-purple-500 text-white border border-purple-500",
|
||||
};
|
||||
}
|
||||
if (tags.includes("system_trust_trusted")) {
|
||||
return {
|
||||
name: "Known",
|
||||
color: "bg-orange-500 text-white border border-orange-500",
|
||||
};
|
||||
}
|
||||
if (tags.includes("system_trust_known")) {
|
||||
return {
|
||||
name: "User",
|
||||
color: "bg-green-500 text-white border border-green-500",
|
||||
};
|
||||
}
|
||||
if (tags.includes("system_trust_basic")) {
|
||||
return {
|
||||
name: "New",
|
||||
color: "bg-blue-500 text-white border border-blue-500",
|
||||
};
|
||||
}
|
||||
if (!tags.some((tag) => tag.startsWith("system_trust_"))) {
|
||||
return {
|
||||
name: "Visitor",
|
||||
color: "bg-gray-500 text-white border border-gray-500",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const badges = useMemo((): BadgeType[] => {
|
||||
if (!profile) return [];
|
||||
if (isFullUser(profile) && Array.isArray(profile.badges)) {
|
||||
return profile.badges ?? [];
|
||||
}
|
||||
return [];
|
||||
}, [profile]);
|
||||
|
||||
const showcasedBadges = badges.filter((badge: BadgeType) => badge.showcased);
|
||||
const otherBadges = badges.filter((badge: BadgeType) => !badge.showcased);
|
||||
const profilePronouns = isFullUser(profile) ? profile.pronouns : undefined;
|
||||
const profileAgeVerified = isFullUser(profile)
|
||||
? profile.ageVerified
|
||||
: undefined;
|
||||
const profileDateJoined = isFullUser(profile)
|
||||
? profile.dateJoined
|
||||
: undefined;
|
||||
const profileLastLogin = isFullUser(profile) ? profile.lastLogin : undefined;
|
||||
const profileLastActivity = isFullUser(profile)
|
||||
? profile.lastActivity
|
||||
: undefined;
|
||||
const profileLastPlatform = isFullUser(profile)
|
||||
? profile.lastPlatform
|
||||
: undefined;
|
||||
const profileBioLinks = isFullUser(profile)
|
||||
? profile.bioLinks ?? []
|
||||
: isLimitedUserFriend(profile)
|
||||
? profile.bioLinks ?? []
|
||||
: [];
|
||||
const profileTags = isFullUser(profile)
|
||||
? profile.tags ?? []
|
||||
: isLimitedUserFriend(profile)
|
||||
? profile.tags ?? []
|
||||
: [];
|
||||
const highestTrustRank = useMemo(() => {
|
||||
if (profileTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return getHighestTrustRank(profileTags);
|
||||
}, [profileTags]);
|
||||
|
||||
const cachedBannerUrl = useCachedImage(profile?.profilePicOverride);
|
||||
|
||||
if (!authLoading && !currentUser) {
|
||||
return <LoginRequired />;
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Card className="border-border/70 bg-card/80 backdrop-blur">
|
||||
<CardContent className="p-6 text-center space-y-2">
|
||||
<h2 className="text-lg font-semibold">Profile unavailable</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{profileError ?? "We could not load this profile right now."}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
Go back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-background bg-cover bg-center bg-fixed"
|
||||
style={{
|
||||
backgroundImage:
|
||||
cachedBannerUrl || profile.profilePicOverride
|
||||
? `linear-gradient(135deg, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.3)), url(${
|
||||
cachedBannerUrl || profile.profilePicOverride
|
||||
})`
|
||||
: "linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--secondary)) 100%)",
|
||||
}}
|
||||
>
|
||||
{/* Content */}
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
{/* Profile Header Card */}
|
||||
<div className="relative mb-6">
|
||||
<Card className="bg-card/80 backdrop-blur supports-[backdrop-filter]:bg-card/80 border-white/10">
|
||||
<CardContent className="pt-5 pb-5">
|
||||
<div className="flex gap-6 items-stretch">
|
||||
{/* Avatar */}
|
||||
<div className="shrink-0 flex items-center">
|
||||
<div className="h-40 w-40 flex-shrink-0">
|
||||
<UserAvatar
|
||||
user={profile}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-cover"
|
||||
fallbackClassName="text-3xl font-bold text-white bg-gradient-to-br from-primary/80 to-secondary/80"
|
||||
statusClassName={getStatusDotClass(profile)}
|
||||
statusSize="18%"
|
||||
statusOffset="2%"
|
||||
statusContainerClassName="bg-background/95 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Display Name Row */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{profile.displayName}
|
||||
</h1>
|
||||
|
||||
{/* Pronouns Chip */}
|
||||
{profilePronouns && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
{profilePronouns}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Chip */}
|
||||
<div className="flex items-center gap-3 text-muted-foreground mb-3 flex-wrap">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs font-normal gap-1.5 cursor-default"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
getStatusBadgeColor(profile)
|
||||
)}
|
||||
/>
|
||||
<span className="capitalize">
|
||||
{isUserOffline(profile)
|
||||
? profile.statusDescription || "Offline"
|
||||
: profile.statusDescription ||
|
||||
profile.status ||
|
||||
"Online"}
|
||||
</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
{profile.status &&
|
||||
(isUserOffline(profile) ||
|
||||
profile.statusDescription) && (
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
Status:{" "}
|
||||
<span className="capitalize">
|
||||
{profile.status}
|
||||
</span>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Languages */}
|
||||
{profileTags.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap mb-3">
|
||||
{profileTags
|
||||
.filter((tag: string) => tag.startsWith("language_"))
|
||||
.map((tag: string) => {
|
||||
const langName = getLanguageName(tag);
|
||||
if (langName) {
|
||||
return (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
{langName}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Featured Badges */}
|
||||
{showcasedBadges.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{showcasedBadges.slice(0, 3).map((badge: BadgeType) => (
|
||||
<div
|
||||
key={badge.badgeId}
|
||||
title={badge.badgeName}
|
||||
className="h-8 w-8 rounded overflow-hidden hover:scale-110 transition-transform"
|
||||
>
|
||||
{badge.badgeImageUrl ? (
|
||||
<CachedImage
|
||||
src={badge.badgeImageUrl}
|
||||
alt={badge.badgeName}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-primary/10">
|
||||
<Award className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{showcasedBadges.length > 3 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs h-8 flex items-center px-2"
|
||||
>
|
||||
+{showcasedBadges.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Side */}
|
||||
<div className="shrink-0 flex gap-2 items-start pt-1.5">
|
||||
{/* Age Verified */}
|
||||
{profileAgeVerified !== undefined && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs font-normal whitespace-nowrap"
|
||||
>
|
||||
{profileAgeVerified ? "Age Verified" : "Not Age Verified"}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Trust Rank */}
|
||||
{highestTrustRank && (
|
||||
<Badge
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
highestTrustRank.color
|
||||
)}
|
||||
>
|
||||
{highestTrustRank.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs Section */}
|
||||
<Card className="bg-card/80 backdrop-blur supports-[backdrop-filter]:bg-card/80 border-white/10">
|
||||
<CardHeader className="pb-3">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="about">
|
||||
<Info className="h-4 w-4 mr-2" />
|
||||
About
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="badges">
|
||||
<Award className="h-4 w-4 mr-2" />
|
||||
Badges
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="details">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Account Details
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* About Tab */}
|
||||
<TabsContent value="about" className="mt-6 space-y-4">
|
||||
{profile.bio ? (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Bio</h3>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-justify">
|
||||
{profile.bio}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No bio</p>
|
||||
)}
|
||||
|
||||
{profileBioLinks.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="text-sm font-semibold flex items-center gap-2 mb-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
Links
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{profileBioLinks.map((link: string, index: number) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="text-xs"
|
||||
>
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkIcon className="h-3 w-3 mr-1" />
|
||||
{getLinkLabel(link)}
|
||||
</a>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Badges Tab */}
|
||||
<TabsContent value="badges" className="mt-6 space-y-4">
|
||||
{badges.length > 0 ? (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold mb-3">
|
||||
Featured Badges ({showcasedBadges.length})
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{showcasedBadges.map((badge: BadgeType) => (
|
||||
<div
|
||||
key={badge.badgeId}
|
||||
className="flex items-start gap-3 p-4 bg-gradient-to-br from-primary/5 to-secondary/5 rounded-lg border border-primary/10 hover:border-primary/20 transition-colors"
|
||||
>
|
||||
{badge.badgeImageUrl ? (
|
||||
<CachedImage
|
||||
src={badge.badgeImageUrl}
|
||||
alt={badge.badgeName}
|
||||
className="h-10 w-10 rounded shrink-0 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Award className="h-10 w-10 text-primary shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">
|
||||
{badge.badgeName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">
|
||||
{badge.badgeDescription || "Badge"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{otherBadges.length > 0 ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">
|
||||
Other Badges ({otherBadges.length})
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{otherBadges.map((badge: BadgeType) => (
|
||||
<div
|
||||
key={badge.badgeId}
|
||||
className="flex items-start gap-3 p-4 bg-muted/50 rounded-lg border border-border hover:border-foreground/20 transition-colors"
|
||||
>
|
||||
{badge.badgeImageUrl ? (
|
||||
<CachedImage
|
||||
src={badge.badgeImageUrl}
|
||||
alt={badge.badgeName}
|
||||
className="h-10 w-10 rounded shrink-0 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Award className="h-10 w-10 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">
|
||||
{badge.badgeName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">
|
||||
{badge.badgeDescription || "Badge"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No badges yet</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Account Details Tab */}
|
||||
<TabsContent value="details" className="mt-6 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase mb-2">
|
||||
User ID
|
||||
</p>
|
||||
<p className="text-sm font-mono text-primary break-all">
|
||||
{profile.id}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase mb-2">
|
||||
Joined
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{profileDateJoined
|
||||
? new Date(profileDateJoined).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
)
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase mb-2">
|
||||
Last Login
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{profileLastLogin
|
||||
? new Date(profileLastLogin).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase mb-2">
|
||||
Last Activity
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{profileLastActivity
|
||||
? new Date(profileLastActivity).toLocaleString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}
|
||||
)
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase mb-2">
|
||||
Platform
|
||||
</p>
|
||||
<p className="text-sm capitalize">
|
||||
{profileLastPlatform || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase mb-2">
|
||||
Status
|
||||
</p>
|
||||
<p className="text-sm capitalize">
|
||||
{profile.status || "Offline"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
src/pages/Settings.tsx
Normal file
185
src/pages/Settings.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
// TODO: Replace placeholder settings page (This is just for testing purposes)
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { developerModeStore } from "@/stores";
|
||||
import { Code2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function Settings() {
|
||||
const { i18n } = useTranslation();
|
||||
const [developerMode, setDeveloperMode] = useState(
|
||||
developerModeStore.getSnapshot() ?? false
|
||||
);
|
||||
const [togglingDevMode, setTogglingDevMode] = useState(false);
|
||||
const [changingLanguage, setChangingLanguage] = useState(false);
|
||||
const [selectedLanguage, setSelectedLanguage] = useState("en");
|
||||
|
||||
const availableLanguages = useMemo(
|
||||
() => [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "ja", label: "Japanese" },
|
||||
{ code: "th", label: "Thai" },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const unsubscribeDevMode = developerModeStore.subscribe((value) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setDeveloperMode(value);
|
||||
});
|
||||
|
||||
developerModeStore.ensure().catch((error) => {
|
||||
console.error("Failed to load developer mode setting", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribeDevMode();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const extractLanguage = (lng?: string) => (lng ?? "en").split("-")[0];
|
||||
setSelectedLanguage(extractLanguage(i18n.language));
|
||||
|
||||
const handleLanguageChanged = (lng: string) => {
|
||||
setSelectedLanguage(extractLanguage(lng));
|
||||
};
|
||||
|
||||
i18n.on("languageChanged", handleLanguageChanged);
|
||||
|
||||
return () => {
|
||||
i18n.off("languageChanged", handleLanguageChanged);
|
||||
};
|
||||
}, [i18n]);
|
||||
|
||||
const handleDeveloperModeToggle = async () => {
|
||||
setTogglingDevMode(true);
|
||||
try {
|
||||
await developerModeStore.toggle();
|
||||
toast.success(
|
||||
`Developer mode ${!developerMode ? "enabled" : "disabled"}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle developer mode", error);
|
||||
toast.error("Failed to update developer mode setting");
|
||||
} finally {
|
||||
setTogglingDevMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
if (value === selectedLanguage) {
|
||||
return;
|
||||
}
|
||||
setChangingLanguage(true);
|
||||
try {
|
||||
await i18n.changeLanguage(value);
|
||||
const languageName =
|
||||
availableLanguages.find((lang) => lang.code === value)?.label ?? value;
|
||||
toast.success(`Language switched to ${languageName}.`);
|
||||
} catch (error) {
|
||||
console.error("Failed to change language", error);
|
||||
toast.error("Failed to change language. Please try again.");
|
||||
} finally {
|
||||
setChangingLanguage(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Configure preferences and developer-focused options for VRC Circle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Language</CardTitle>
|
||||
<CardDescription>
|
||||
Select the language you would like to use in the app.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select
|
||||
value={selectedLanguage}
|
||||
onValueChange={handleLanguageChange}
|
||||
disabled={changingLanguage}
|
||||
>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableLanguages.map((language) => (
|
||||
<SelectItem key={language.code} value={language.code}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Code2 className="h-5 w-5" />
|
||||
Developer Options
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enable advanced features for debugging and development
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="developer-mode"
|
||||
className="text-base font-medium"
|
||||
>
|
||||
Developer Mode
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access logs, developer tools, and advanced debugging features
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
id="developer-mode"
|
||||
variant={developerMode ? "default" : "outline"}
|
||||
onClick={handleDeveloperModeToggle}
|
||||
disabled={togglingDevMode}
|
||||
>
|
||||
{developerMode ? "On" : "Off"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
src/pages/StoreStudio.tsx
Normal file
195
src/pages/StoreStudio.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
// TODO: Localize strings
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Database, ListTree, User } from "lucide-react";
|
||||
import { StoreViewer } from "@/components/store-studio/StoreViewer";
|
||||
import { StoreStudioError } from "@/components/store-studio/StoreStudioError";
|
||||
import { StoreRegistry } from "@/services/storeRegistry";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { getActiveAccountId } from "@/stores/account-scope";
|
||||
|
||||
interface StoreStudioProps {
|
||||
heading?: boolean;
|
||||
}
|
||||
|
||||
export function StoreStudio({ heading = false }: StoreStudioProps) {
|
||||
console.log("[StoreStudio] Component rendering");
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
// Manage stores in state so we can retry loading without relying on globals
|
||||
const [allStores, setAllStores] = useState(() =>
|
||||
StoreRegistry.getAllStores()
|
||||
);
|
||||
const [selectedStoreId, setSelectedStoreId] = useState<string>(
|
||||
allStores[0]?.id || "user"
|
||||
);
|
||||
const selectedStore = allStores.find((store) => store.id === selectedStoreId);
|
||||
const activeAccountId = getActiveAccountId();
|
||||
|
||||
// Try to load stores and catch any errors that may have been thrown during module init
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadStores() {
|
||||
try {
|
||||
const stores = await Promise.resolve(StoreRegistry.getAllStores());
|
||||
if (mounted) {
|
||||
setAllStores(stores);
|
||||
// Ensure selectedStoreId stays valid
|
||||
setSelectedStoreId(
|
||||
(prev) =>
|
||||
stores.find((s) => s.id === prev)?.id || stores[0]?.id || "user"
|
||||
);
|
||||
setConnectionError(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (mounted) {
|
||||
setConnectionError(
|
||||
err?.message || String(err) || "Unknown error loading stores"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadStores();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (connectionError) {
|
||||
return (
|
||||
<StoreStudioError
|
||||
onRetry={() => {
|
||||
setConnectionError(null);
|
||||
try {
|
||||
const stores = StoreRegistry.getAllStores();
|
||||
setAllStores(stores);
|
||||
setSelectedStoreId(stores[0]?.id || "user");
|
||||
} catch (err: any) {
|
||||
setConnectionError(
|
||||
err?.message || String(err) || "Unknown error loading stores"
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[StoreStudio] Selected store:",
|
||||
selectedStore?.label,
|
||||
"Active account:",
|
||||
activeAccountId,
|
||||
"User:",
|
||||
user?.displayName
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{heading ? (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Store Studio</h2>
|
||||
<p className="text-muted-foreground mt-2 max-w-2xl">
|
||||
Inspect and manage front-end store caches in real-time. Use this to
|
||||
debug stale data, verify cache scopes, and trigger manual refreshes.
|
||||
Debug mode allows editing store content directly.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Current Scope Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Current Scope
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
Resource stores (User, Worlds, Avatars) cache data per account
|
||||
scope
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{user && (
|
||||
<>
|
||||
<Badge variant="default" className="font-mono text-xs">
|
||||
{user.displayName}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{activeAccountId || "No scope"}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{!user && <Badge variant="outline">Not logged in</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4">
|
||||
{/* Sidebar */}
|
||||
<Card className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
All Stores
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Select a store to inspect and manage its data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[70vh]">
|
||||
<div className="space-y-2 pr-4">
|
||||
{allStores.map((store) => (
|
||||
<Button
|
||||
key={store.id}
|
||||
variant={
|
||||
store.id === selectedStoreId ? "default" : "outline"
|
||||
}
|
||||
className="w-full justify-start"
|
||||
onClick={() => setSelectedStoreId(store.id)}
|
||||
>
|
||||
<ListTree className="h-4 w-4 mr-2" />
|
||||
{store.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="space-y-4">
|
||||
{selectedStore ? (
|
||||
<StoreViewer store={selectedStore} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>No store selected</CardTitle>
|
||||
<CardDescription>
|
||||
Select a store from the sidebar to view its contents
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
src/pages/Verify2FA.tsx
Normal file
186
src/pages/Verify2FA.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { AlertCircle, KeyRound, Mail, ShieldCheck } from "lucide-react";
|
||||
import { VRChatError } from "@/types/errors";
|
||||
|
||||
export function Verify2FA() {
|
||||
const { t } = useTranslation();
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { verify2FA, twoFactorMethods } = useAuth();
|
||||
|
||||
const twoFactorMethod =
|
||||
twoFactorMethods.includes("totp") || twoFactorMethods.includes("otp")
|
||||
? "totp"
|
||||
: "emailOtp";
|
||||
|
||||
const isEmailOtp = twoFactorMethod === "emailOtp";
|
||||
const isTOTP = twoFactorMethod === "totp";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const verified = await verify2FA(code, twoFactorMethod);
|
||||
|
||||
if (verified) {
|
||||
navigate("/");
|
||||
} else {
|
||||
setError(t("page.verify2fa.invalid"));
|
||||
setCode("");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof VRChatError) {
|
||||
setError(err.getUserMessage());
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError(
|
||||
t(
|
||||
"unexpectedError",
|
||||
"An unexpected error occurred. Please try again."
|
||||
)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
|
||||
setCode(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div className="absolute top-4 right-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<ShieldCheck className="h-8 w-8" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("page.verify2fa.title")}
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
{isTOTP ? (
|
||||
<>
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
{t("page.verify2fa.authenticator")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
{t("page.verify2fa.email")}
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2FA Card */}
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardHeader className="space-y-1 pb-4">
|
||||
<CardTitle className="text-xl">
|
||||
{t("page.verify2fa.enterCode")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isEmailOtp
|
||||
? t("page.verify2fa.emailInstruction")
|
||||
: t("page.verify2fa.appInstruction")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span className="flex-1">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="code">{t("page.verify2fa.code")}</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{code.length}/6
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
id="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
placeholder={t("page.verify2fa.placeholder")}
|
||||
required
|
||||
disabled={loading}
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
className="text-center text-2xl tracking-widest font-mono h-14"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-10"
|
||||
disabled={loading || code.length !== 6}
|
||||
>
|
||||
{loading
|
||||
? t("page.verify2fa.verifying")
|
||||
: t("page.verify2fa.verify")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full h-10"
|
||||
onClick={() => navigate("/login")}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("page.verify2fa.back")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("page.verify2fa.help")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
366
src/pages/Worlds.tsx
Normal file
366
src/pages/Worlds.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { worldsStore } from "@/stores/worlds-store";
|
||||
import type { LimitedWorld } from "@/types/bindings";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { LoginRequired } from "@/components/LoginRequired";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CachedImage } from "@/components/CachedImage";
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat(undefined, {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
function formatNumber(value?: number | null): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "0";
|
||||
}
|
||||
return numberFormatter.format(value);
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) {
|
||||
return "Unknown";
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return "Unknown";
|
||||
}
|
||||
return dateFormatter.format(parsed);
|
||||
}
|
||||
|
||||
function platformLabel(platform?: string | null): string | null {
|
||||
if (!platform) {
|
||||
return null;
|
||||
}
|
||||
const normalized = platform.toLowerCase();
|
||||
switch (normalized) {
|
||||
case "standalonewindows":
|
||||
case "standalonewindows64":
|
||||
return "PC";
|
||||
case "android":
|
||||
return "Quest";
|
||||
default:
|
||||
return platform;
|
||||
}
|
||||
}
|
||||
|
||||
function extractPlatforms(world: LimitedWorld): string[] {
|
||||
const platforms = new Set<string>();
|
||||
world.unityPackages?.forEach((pkg) => {
|
||||
const label = platformLabel(pkg.platform);
|
||||
if (label) {
|
||||
platforms.add(label);
|
||||
}
|
||||
});
|
||||
return Array.from(platforms);
|
||||
}
|
||||
|
||||
function cleanTag(tag: string): string {
|
||||
if (tag.startsWith("author_tag_")) {
|
||||
return tag.replace("author_tag_", "");
|
||||
}
|
||||
if (tag.startsWith("system_")) {
|
||||
return tag.replace("system_", "");
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
export function Worlds() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [worlds, setWorlds] = useState<LimitedWorld[] | null>(
|
||||
worldsStore.getSnapshot()
|
||||
);
|
||||
const [loading, setLoading] = useState(!worlds);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const handleUpdate = (value: LimitedWorld[] | null) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setWorlds(value);
|
||||
setLoading(false);
|
||||
if (value) {
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = worldsStore.subscribe(handleUpdate);
|
||||
|
||||
const snapshot = worldsStore.getSnapshot();
|
||||
setWorlds(snapshot);
|
||||
|
||||
if (!user) {
|
||||
setWorlds(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
worldsStore.ensure().catch((err) => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to load worlds";
|
||||
setError(message);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await worldsStore.refresh();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to refresh worlds";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return <LoginRequired />;
|
||||
}
|
||||
|
||||
const hasWorlds = (worlds?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="w-full space-y-6">
|
||||
<div className="flex flex-col-reverse gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t("page.worlds.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t("page.worlds.description")}
|
||||
</p>
|
||||
{hasWorlds && !loading && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{worlds && worlds.length === 1
|
||||
? t("page.worlds.showingSingle", { count: worlds.length })
|
||||
: t("page.worlds.showingMultiple", {
|
||||
count: worlds?.length ?? 0,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading
|
||||
? t("page.worlds.refresh.loading")
|
||||
: t("page.worlds.refresh.label")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("page.worlds.error.title")}</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button size="sm" onClick={handleRefresh} disabled={loading}>
|
||||
{t("page.worlds.error.tryAgain")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Card key={index} className="overflow-hidden">
|
||||
<div className="aspect-video bg-muted animate-pulse" />
|
||||
<CardHeader className="gap-2">
|
||||
<div className="h-6 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 bg-muted animate-pulse rounded w-3/4" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="h-4 bg-muted animate-pulse rounded" />
|
||||
<div className="h-4 bg-muted animate-pulse rounded w-2/3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !hasWorlds && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("page.worlds.noItemsTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("page.worlds.noItemsDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && hasWorlds && (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{worlds!.map((world) => {
|
||||
const platforms = extractPlatforms(world);
|
||||
const previewUrl =
|
||||
world.imageUrl ?? world.thumbnailImageUrl ?? undefined;
|
||||
const updatedLabel = formatDate(
|
||||
world.updatedAt ?? world.publicationDate ?? world.createdAt
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={world.id} className="overflow-hidden flex flex-col">
|
||||
<div className="relative aspect-video bg-muted">
|
||||
{previewUrl ? (
|
||||
<CachedImage
|
||||
src={previewUrl}
|
||||
alt={world.name}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{t("page.worlds.noPreview")}
|
||||
</div>
|
||||
)}
|
||||
{world.releaseStatus && (
|
||||
<Badge
|
||||
className="absolute left-4 top-4 uppercase"
|
||||
variant="secondary"
|
||||
>
|
||||
{world.releaseStatus}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardHeader className="flex-0 space-y-2">
|
||||
<CardTitle className="leading-tight">
|
||||
{world.name}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{world.authorName
|
||||
? t("page.worlds.byAuthor", {
|
||||
author: world.authorName,
|
||||
})
|
||||
: t("page.worlds.authorUnknown")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
{world.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||
{world.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("page.worlds.labels.visits")}
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{formatNumber(world.visits ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("page.worlds.labels.favorites")}
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{formatNumber(world.favorites ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("page.worlds.labels.capacity")}
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{world.capacity ?? "—"}
|
||||
{world.recommendedCapacity
|
||||
? ` / ${world.recommendedCapacity}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{platforms.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{platforms.map((platform) => (
|
||||
<Badge
|
||||
key={`${world.id}-${platform}`}
|
||||
variant="outline"
|
||||
>
|
||||
{platform}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{world.tags && world.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
{world.tags.slice(0, 6).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-muted px-2 py-1"
|
||||
>
|
||||
#{cleanTag(tag)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("page.worlds.labels.updatedPrefix", {
|
||||
date: updatedLabel,
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/services/account.ts
Normal file
53
src/services/account.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { User, StoredAccount } from '../types/bindings';
|
||||
import { parseVRCError } from '../types/errors';
|
||||
|
||||
export class AccountService {
|
||||
static async saveCurrentAccount(user: User): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>('save_current_account', { user });
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getAllAccounts(): Promise<StoredAccount[]> {
|
||||
try {
|
||||
return await invoke<StoredAccount[]>('get_all_accounts');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async switchAccount(userId: string): Promise<User> {
|
||||
try {
|
||||
return await invoke<User>('switch_account', { userId });
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async removeAccount(userId: string): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>('remove_account', { userId });
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async clearAllAccounts(): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>('clear_all_accounts');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async loadLastAccount(): Promise<User | null> {
|
||||
try {
|
||||
return await invoke<User | null>('load_last_account');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/services/database.ts
Normal file
54
src/services/database.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { parseVRCError } from '../types/errors';
|
||||
import type { TableInfo, ColumnInfo, QueryResult } from '../types/bindings';
|
||||
|
||||
export class DatabaseService {
|
||||
static async listTables(): Promise<TableInfo[]> {
|
||||
try {
|
||||
return await invoke<TableInfo[]>('db_list_tables');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getTableSchema(tableName: string): Promise<ColumnInfo[]> {
|
||||
try {
|
||||
return await invoke<ColumnInfo[]>('db_get_table_schema', { tableName });
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getTableData(
|
||||
tableName: string,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<QueryResult> {
|
||||
try {
|
||||
return await invoke<QueryResult>('db_get_table_data', {
|
||||
tableName,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getTableCount(tableName: string): Promise<number> {
|
||||
try {
|
||||
return await invoke<number>('db_get_table_count', { tableName });
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async executeQuery(query: string): Promise<QueryResult> {
|
||||
try {
|
||||
return await invoke<QueryResult>('db_execute_query', { query });
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
32
src/services/image-cache.ts
Normal file
32
src/services/image-cache.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { commands, type VRCError } from '@/types/bindings';
|
||||
|
||||
function formatVRCError(error: VRCError): string {
|
||||
switch (error.type) {
|
||||
case 'Network':
|
||||
case 'Authentication':
|
||||
case 'RateLimit':
|
||||
case 'Parse':
|
||||
case 'InvalidInput':
|
||||
case 'Unknown':
|
||||
return error.data;
|
||||
case 'Http':
|
||||
return `HTTP ${error.data.status}: ${error.data.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageCacheService {
|
||||
static async checkCached(url: string): Promise<string | null> {
|
||||
const result = await commands.checkImageCached(url);
|
||||
return result.status === 'ok' ? result.data : null;
|
||||
}
|
||||
|
||||
static async cache(url: string): Promise<string> {
|
||||
const result = await commands.cacheImage(url);
|
||||
if (result.status === 'error') {
|
||||
// Include the full error details for debugging
|
||||
const errorMsg = formatVRCError(result.error);
|
||||
throw new Error(`Failed to cache image: ${errorMsg}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
32
src/services/logs.ts
Normal file
32
src/services/logs.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { parseVRCError } from '../types/errors';
|
||||
import type { LogEntry as BackendLogEntry } from '../types/bindings';
|
||||
import type { LogEntry as FrontendLogEntry } from '../utils/logger';
|
||||
|
||||
export type CombinedLogEntry = BackendLogEntry | FrontendLogEntry;
|
||||
|
||||
export class LogsService {
|
||||
static async getBackendLogs(): Promise<BackendLogEntry[]> {
|
||||
try {
|
||||
return await invoke<BackendLogEntry[]>('get_backend_logs');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async clearBackendLogs(): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>('clear_backend_logs');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async exportBackendLogs(): Promise<string> {
|
||||
try {
|
||||
return await invoke<string>('export_backend_logs');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/services/settings.ts
Normal file
37
src/services/settings.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { AppSettings } from '../types/bindings';
|
||||
import { parseVRCError } from '../types/errors';
|
||||
|
||||
export class SettingsService {
|
||||
static async getSettings(): Promise<AppSettings> {
|
||||
try {
|
||||
return await invoke<AppSettings>('get_settings');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async saveSettings(settings: AppSettings): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>('save_settings', { settings });
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getDeveloperMode(): Promise<boolean> {
|
||||
try {
|
||||
return await invoke<boolean>('get_developer_mode');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async setDeveloperMode(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>('set_developer_mode', { enabled });
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
504
src/services/storeRegistry.tsx
Normal file
504
src/services/storeRegistry.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
import {
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
TimerReset,
|
||||
Database,
|
||||
ListTree,
|
||||
} from "lucide-react";
|
||||
import type { StoreEntry } from "@/types/store-studio";
|
||||
import { userStore } from "@/stores/user-store";
|
||||
import { avatarsStore } from "@/stores/avatars-store";
|
||||
import { worldsStore } from "@/stores/worlds-store";
|
||||
import { accountsStore } from "@/stores/accounts-store";
|
||||
import { developerModeStore } from "@/stores/developer-mode-store";
|
||||
import { vrchatStatusStore } from "@/stores/vrchat-status-store";
|
||||
import { alertStore } from "@/stores/alert-store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export class StoreRegistry {
|
||||
/**
|
||||
* Get all resource stores (stores that extend ResourceStore)
|
||||
*/
|
||||
static getResourceStores(): StoreEntry[] {
|
||||
return [
|
||||
{
|
||||
id: "user",
|
||||
label: "User Store",
|
||||
description: "Cached current user data fetched from VRChat API",
|
||||
getData: () => userStore.getSnapshot(),
|
||||
subscribe: (listener) => userStore.subscribe(listener),
|
||||
actions: [
|
||||
{
|
||||
id: "ensure",
|
||||
label: "Ensure",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: async () => {
|
||||
await userStore.ensure();
|
||||
toast.success("User store ensured");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "refresh",
|
||||
label: "Force Refresh",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: async () => {
|
||||
await userStore.refresh();
|
||||
toast.success("User store refreshed");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mark-stale",
|
||||
label: "Mark Stale",
|
||||
icon: <TimerReset className="h-4 w-4 mr-2" />,
|
||||
onClick: () => {
|
||||
userStore.markStale();
|
||||
toast.success("User store marked as stale");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
label: "Clear",
|
||||
icon: <Trash2 className="h-4 w-4 mr-2" />,
|
||||
variant: "destructive" as const,
|
||||
onClick: () => {
|
||||
userStore.clear();
|
||||
toast.success("User store cleared");
|
||||
},
|
||||
},
|
||||
],
|
||||
debugInfo: () => {
|
||||
const scopes = userStore.debugScopes();
|
||||
const activeScope = scopes.find((s) => s.isActiveScope);
|
||||
return {
|
||||
updatedAt: activeScope?.updatedAt || null,
|
||||
ageMs: activeScope?.ageMs || null,
|
||||
stale: activeScope?.stale || false,
|
||||
inflight: activeScope?.inflight || false,
|
||||
scopes: scopes.map((scope) => ({
|
||||
...scope,
|
||||
label: scope.scopeId || "Global / No Scope",
|
||||
actions: [],
|
||||
})),
|
||||
};
|
||||
},
|
||||
canEdit: true,
|
||||
setData: (value) => {
|
||||
if (value && typeof value === "object") {
|
||||
userStore.set(value as any); // Type assertion for dynamic store editing
|
||||
} else if (value === null) {
|
||||
userStore.clear();
|
||||
} else {
|
||||
throw new Error("Invalid user data");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "worlds",
|
||||
label: "Worlds Store",
|
||||
description: "Cached uploaded world metadata from VRChat API",
|
||||
getData: () => worldsStore.getSnapshot(),
|
||||
subscribe: (listener) => worldsStore.subscribe(listener),
|
||||
actions: [
|
||||
{
|
||||
id: "ensure",
|
||||
label: "Ensure",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: async () => {
|
||||
await worldsStore.ensure();
|
||||
toast.success("Worlds store ensured");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "refresh",
|
||||
label: "Force Refresh",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: async () => {
|
||||
await worldsStore.refresh();
|
||||
toast.success("Worlds store refreshed");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mark-stale",
|
||||
label: "Mark Stale",
|
||||
icon: <TimerReset className="h-4 w-4 mr-2" />,
|
||||
onClick: () => {
|
||||
worldsStore.markStale();
|
||||
toast.success("Worlds store marked as stale");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
label: "Clear",
|
||||
icon: <Trash2 className="h-4 w-4 mr-2" />,
|
||||
variant: "destructive" as const,
|
||||
onClick: () => {
|
||||
worldsStore.clear();
|
||||
toast.success("Worlds store cleared");
|
||||
},
|
||||
},
|
||||
],
|
||||
debugInfo: () => {
|
||||
const scopes = worldsStore.debugScopes();
|
||||
const activeScope = scopes.find((s) => s.isActiveScope);
|
||||
return {
|
||||
updatedAt: activeScope?.updatedAt || null,
|
||||
ageMs: activeScope?.ageMs || null,
|
||||
stale: activeScope?.stale || false,
|
||||
inflight: activeScope?.inflight || false,
|
||||
scopes: scopes.map((scope) => ({
|
||||
...scope,
|
||||
label: scope.scopeId || "Global / No Scope",
|
||||
actions: [],
|
||||
})),
|
||||
};
|
||||
},
|
||||
canEdit: true,
|
||||
setData: (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
worldsStore.set(value);
|
||||
} else if (value === null) {
|
||||
worldsStore.clear();
|
||||
} else {
|
||||
throw new Error("Worlds data must be an array");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "avatars",
|
||||
label: "Avatars Store",
|
||||
description: "Cached uploaded avatar metadata from VRChat API",
|
||||
getData: () => avatarsStore.getSnapshot(),
|
||||
subscribe: (listener) => avatarsStore.subscribe(listener),
|
||||
actions: [
|
||||
{
|
||||
id: "ensure",
|
||||
label: "Ensure",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: async () => {
|
||||
await avatarsStore.ensure();
|
||||
toast.success("Avatars store ensured");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "refresh",
|
||||
label: "Force Refresh",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: async () => {
|
||||
await avatarsStore.refresh();
|
||||
toast.success("Avatars store refreshed");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mark-stale",
|
||||
label: "Mark Stale",
|
||||
icon: <TimerReset className="h-4 w-4 mr-2" />,
|
||||
onClick: () => {
|
||||
avatarsStore.markStale();
|
||||
toast.success("Avatars store marked as stale");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
label: "Clear",
|
||||
icon: <Trash2 className="h-4 w-4 mr-2" />,
|
||||
variant: "destructive" as const,
|
||||
onClick: () => {
|
||||
avatarsStore.clear();
|
||||
toast.success("Avatars store cleared");
|
||||
},
|
||||
},
|
||||
],
|
||||
debugInfo: () => {
|
||||
const scopes = avatarsStore.debugScopes();
|
||||
const activeScope = scopes.find((s) => s.isActiveScope);
|
||||
return {
|
||||
updatedAt: activeScope?.updatedAt || null,
|
||||
ageMs: activeScope?.ageMs || null,
|
||||
stale: activeScope?.stale || false,
|
||||
inflight: activeScope?.inflight || false,
|
||||
scopes: scopes.map((scope) => ({
|
||||
...scope,
|
||||
label: scope.scopeId || "Global / No Scope",
|
||||
actions: [],
|
||||
})),
|
||||
};
|
||||
},
|
||||
canEdit: true,
|
||||
setData: (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
avatarsStore.set(value);
|
||||
} else if (value === null) {
|
||||
avatarsStore.clear();
|
||||
} else {
|
||||
throw new Error("Avatars data must be an array");
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all simple stores (non-resource stores)
|
||||
*/
|
||||
static getSimpleStores(): StoreEntry[] {
|
||||
return [
|
||||
{
|
||||
id: "accounts",
|
||||
label: "Accounts Store",
|
||||
description:
|
||||
"Cached VRChat account credentials for quick account switching",
|
||||
getData: () => accountsStore.getSnapshot(),
|
||||
subscribe: (listener) => accountsStore.subscribe(listener),
|
||||
actions: [
|
||||
{
|
||||
id: "ensure",
|
||||
label: "Ensure",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: async () => {
|
||||
await accountsStore.ensure();
|
||||
toast.success("Accounts cache validated");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "refresh",
|
||||
label: "Force Refresh",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: async () => {
|
||||
await accountsStore.refresh();
|
||||
toast.success("Accounts cache refreshed");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
label: "Clear",
|
||||
icon: <Trash2 className="h-4 w-4 mr-2" />,
|
||||
variant: "destructive" as const,
|
||||
onClick: () => {
|
||||
accountsStore.clear();
|
||||
toast.success("Accounts cache cleared");
|
||||
},
|
||||
},
|
||||
],
|
||||
canEdit: true,
|
||||
setData: (value) => {
|
||||
if (Array.isArray(value)) {
|
||||
// Clear and re-add accounts
|
||||
accountsStore.set(value);
|
||||
} else if (value === null) {
|
||||
accountsStore.clear();
|
||||
} else {
|
||||
throw new Error("Accounts data must be an array");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "developer-mode",
|
||||
label: "Developer Mode Store",
|
||||
description:
|
||||
"Tracks developer mode state, persisted to settings backend",
|
||||
getData: () => developerModeStore.getSnapshot(),
|
||||
subscribe: (listener) => developerModeStore.subscribe(listener),
|
||||
actions: [
|
||||
{
|
||||
id: "ensure",
|
||||
label: "Ensure",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: async () => {
|
||||
await developerModeStore.ensure();
|
||||
toast.success("Developer mode cache ensured");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "refresh",
|
||||
label: "Force Refresh",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: async () => {
|
||||
await developerModeStore.refresh();
|
||||
toast.success("Developer mode refreshed");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "toggle",
|
||||
label: "Toggle",
|
||||
icon: <ListTree className="h-4 w-4 mr-2" />,
|
||||
variant: "secondary" as const,
|
||||
onClick: async () => {
|
||||
const newValue = await developerModeStore.toggle();
|
||||
toast.success(
|
||||
`Developer mode ${newValue ? "enabled" : "disabled"}`
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
canEdit: true,
|
||||
setData: (value) => {
|
||||
if (typeof value === "boolean") {
|
||||
developerModeStore.set(value);
|
||||
} else {
|
||||
throw new Error("Developer mode value must be a boolean");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "vrchat-status",
|
||||
label: "VRChat Status Store",
|
||||
description:
|
||||
"Monitors VRChat service status via polling the status API",
|
||||
getData: () => vrchatStatusStore.getSnapshot(),
|
||||
subscribe: (listener) => vrchatStatusStore.subscribe(listener),
|
||||
actions: [
|
||||
{
|
||||
id: "fetch",
|
||||
label: "Refresh Now",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: async () => {
|
||||
await vrchatStatusStore.fetchStatus();
|
||||
toast.success("VRChat status refreshed");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "start-polling",
|
||||
label: "Start Polling",
|
||||
icon: <Database className="h-4 w-4 mr-2" />,
|
||||
onClick: () => {
|
||||
vrchatStatusStore.startPolling(300000);
|
||||
toast.success("Polling started (5 min interval)");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "stop-polling",
|
||||
label: "Stop Polling",
|
||||
icon: <Database className="h-4 w-4 mr-2" />,
|
||||
onClick: () => {
|
||||
vrchatStatusStore.stopPolling();
|
||||
toast.success("Polling stopped");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
label: "Clear",
|
||||
icon: <Trash2 className="h-4 w-4 mr-2" />,
|
||||
variant: "destructive" as const,
|
||||
onClick: () => {
|
||||
vrchatStatusStore.clear();
|
||||
toast.success("VRChat status cache cleared");
|
||||
},
|
||||
},
|
||||
],
|
||||
canEdit: false,
|
||||
},
|
||||
{
|
||||
id: "alert",
|
||||
label: "Alert Store",
|
||||
description:
|
||||
"Manages application-wide alerts with navigation and dismissal",
|
||||
getData: () => alertStore.getSnapshot(),
|
||||
subscribe: (listener) => alertStore.subscribe(listener),
|
||||
actions: [
|
||||
{
|
||||
id: "add-test",
|
||||
label: "Add Test Alert",
|
||||
icon: <Database className="h-4 w-4 mr-2" />,
|
||||
onClick: () => {
|
||||
alertStore.addAlert({
|
||||
variant: "info",
|
||||
message: `Test alert ${Date.now()}`,
|
||||
dismissable: true,
|
||||
});
|
||||
toast.success("Test alert added");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "next",
|
||||
label: "Next Alert",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: () => {
|
||||
alertStore.nextAlert();
|
||||
toast.success("Navigated to next alert");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "previous",
|
||||
label: "Previous Alert",
|
||||
icon: <RefreshCw className="h-4 w-4 mr-2" />,
|
||||
onClick: () => {
|
||||
alertStore.previousAlert();
|
||||
toast.success("Navigated to previous alert");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clear-dismissable",
|
||||
label: "Clear Dismissable",
|
||||
icon: <Trash2 className="h-4 w-4 mr-2" />,
|
||||
onClick: () => {
|
||||
alertStore.clearDismissable();
|
||||
toast.success("Dismissable alerts cleared");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clear-all",
|
||||
label: "Clear All",
|
||||
icon: <Trash2 className="h-4 w-4 mr-2" />,
|
||||
variant: "destructive" as const,
|
||||
onClick: () => {
|
||||
alertStore.clearAll();
|
||||
toast.success("All alerts cleared");
|
||||
},
|
||||
},
|
||||
],
|
||||
canEdit: true,
|
||||
setData: (value) => {
|
||||
// This is a more complex store
|
||||
// good idea to validate the structure
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"alerts" in value &&
|
||||
"currentIndex" in value &&
|
||||
Array.isArray((value as { alerts: unknown[] }).alerts)
|
||||
) {
|
||||
// For now, we'll just clear and re-add alerts
|
||||
alertStore.clearAll();
|
||||
const state = value as {
|
||||
alerts: Array<{
|
||||
variant: "info" | "warning" | "error" | "critical";
|
||||
message: string;
|
||||
dismissable: boolean;
|
||||
}>;
|
||||
currentIndex: number;
|
||||
};
|
||||
state.alerts.forEach((alert) => {
|
||||
alertStore.addAlert({
|
||||
variant: alert.variant,
|
||||
message: alert.message,
|
||||
dismissable: alert.dismissable,
|
||||
});
|
||||
});
|
||||
if (
|
||||
state.currentIndex >= 0 &&
|
||||
state.currentIndex < state.alerts.length
|
||||
) {
|
||||
alertStore.setCurrentIndex(state.currentIndex);
|
||||
}
|
||||
} else {
|
||||
throw new Error("Invalid alert store structure");
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stores (both resource and simple)
|
||||
*/
|
||||
static getAllStores(): StoreEntry[] {
|
||||
return [...this.getResourceStores(), ...this.getSimpleStores()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a store by ID
|
||||
*/
|
||||
static getStoreById(id: string): StoreEntry | undefined {
|
||||
return this.getAllStores().find((store) => store.id === id);
|
||||
}
|
||||
}
|
||||
95
src/services/vrchat.ts
Normal file
95
src/services/vrchat.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { User, LoginResult, LimitedUserFriend, UserStatus } from '../types/bindings';
|
||||
import type { LimitedAvatar } from '../types/bindings';
|
||||
import type { LimitedWorld } from '../types/bindings';
|
||||
import { parseVRCError } from '../types/errors';
|
||||
|
||||
export class VRChatService {
|
||||
static async login(email: string, password: string): Promise<LoginResult> {
|
||||
try {
|
||||
return await invoke<LoginResult>('vrchat_login', { email, password });
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async verify2FA(code: string, method: string): Promise<boolean> {
|
||||
try {
|
||||
return await invoke<boolean>('vrchat_verify_2fa', { code, method });
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getCurrentUser(): Promise<User> {
|
||||
try {
|
||||
return await invoke<User>('vrchat_get_current_user');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateStatus(status: UserStatus | string, statusDescription: string): Promise<User> {
|
||||
try {
|
||||
return await invoke<User>('vrchat_update_status', { status, statusDescription });
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async logout(): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>('vrchat_logout');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async checkSession(): Promise<boolean> {
|
||||
try {
|
||||
return await invoke<boolean>('vrchat_check_session');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async clearSession(): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>('vrchat_clear_session');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getOnlineFriends(): Promise<LimitedUserFriend[]> {
|
||||
try {
|
||||
return await invoke<LimitedUserFriend[]>('vrchat_get_online_friends');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getUploadedWorlds(): Promise<LimitedWorld[]> {
|
||||
try {
|
||||
return await invoke<LimitedWorld[]>('vrchat_get_uploaded_worlds');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getUploadedAvatars(): Promise<LimitedAvatar[]> {
|
||||
try {
|
||||
return await invoke<LimitedAvatar[]>('vrchat_get_uploaded_avatars');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async getUserById(userId: string): Promise<User> {
|
||||
try {
|
||||
return await invoke<User>('get_user_by_id', { userId });
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/services/websocket.ts
Normal file
20
src/services/websocket.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { parseVRCError } from '../types/errors';
|
||||
|
||||
export class WebSocketService {
|
||||
static async start(): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>('websocket_start');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
|
||||
static async stop(): Promise<void> {
|
||||
try {
|
||||
return await invoke<void>('websocket_stop');
|
||||
} catch (error) {
|
||||
throw parseVRCError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/stores/account-scope.ts
Normal file
32
src/stores/account-scope.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
type AccountListener = (accountId: string | null) => void;
|
||||
|
||||
let currentAccountId: string | null = null;
|
||||
const listeners = new Set<AccountListener>();
|
||||
|
||||
export function getActiveAccountId(): string | null {
|
||||
return currentAccountId;
|
||||
}
|
||||
|
||||
export function setActiveAccountId(accountId: string | null): void {
|
||||
if (currentAccountId === accountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentAccountId = accountId;
|
||||
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(accountId);
|
||||
} catch (error) {
|
||||
console.error('Account scope listener error', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeActiveAccount(listener: AccountListener): () => void {
|
||||
listeners.add(listener);
|
||||
listener(currentAccountId);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
110
src/stores/accounts-store.ts
Normal file
110
src/stores/accounts-store.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { StoredAccount, User } from '@/types/bindings';
|
||||
import { AccountService } from '@/services/account';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { registerSingleton } from './singleton-registry';
|
||||
|
||||
type AccountsListener = (value: StoredAccount[] | null) => void;
|
||||
|
||||
interface EnsureOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
interface SetOptions {
|
||||
stale?: boolean;
|
||||
}
|
||||
|
||||
export class AccountsStore {
|
||||
private listeners = new Set<AccountsListener>();
|
||||
private cache: StoredAccount[] | null = null;
|
||||
private inflight: Promise<StoredAccount[]> | null = null;
|
||||
private updatedAt = 0;
|
||||
private readonly staleTime: number;
|
||||
|
||||
constructor(options: { staleTime?: number } = {}) {
|
||||
this.staleTime = options.staleTime ?? 60_000;
|
||||
}
|
||||
|
||||
getSnapshot(): StoredAccount[] | null {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
subscribe(listener: AccountsListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.cache);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async ensure(options: EnsureOptions = {}): Promise<StoredAccount[]> {
|
||||
const force = options.force ?? false;
|
||||
if (!force && this.cache && !this.isStale()) {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
if (this.inflight) {
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
this.inflight = AccountService.getAllAccounts()
|
||||
.then((accounts) => {
|
||||
this.set(accounts);
|
||||
return accounts;
|
||||
})
|
||||
.finally(() => {
|
||||
this.inflight = null;
|
||||
});
|
||||
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
refresh(): Promise<StoredAccount[]> {
|
||||
return this.ensure({ force: true });
|
||||
}
|
||||
|
||||
set(value: StoredAccount[] | null, options: SetOptions = {}): void {
|
||||
this.cache = value;
|
||||
this.updatedAt = value && !options.stale ? Date.now() : 0;
|
||||
this.emit(value);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.set(null);
|
||||
}
|
||||
|
||||
async saveFromUser(user: User): Promise<void> {
|
||||
await AccountService.saveCurrentAccount(user);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async removeAccount(userId: string): Promise<void> {
|
||||
await AccountService.removeAccount(userId);
|
||||
if (this.cache) {
|
||||
this.set(this.cache.filter((account) => account.user_id !== userId));
|
||||
}
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
await AccountService.clearAllAccounts();
|
||||
this.clear();
|
||||
}
|
||||
|
||||
private isStale(): boolean {
|
||||
if (!this.cache) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - this.updatedAt > this.staleTime;
|
||||
}
|
||||
|
||||
private emit(value: StoredAccount[] | null): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(value);
|
||||
} catch (error) {
|
||||
logger.error('AccountsStore', 'AccountsStore listener error', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const accountsStore = registerSingleton('accounts', () => new AccountsStore());
|
||||
204
src/stores/alert-store.ts
Normal file
204
src/stores/alert-store.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { logger } from '@/utils/logger';
|
||||
import { registerSingleton } from './singleton-registry';
|
||||
|
||||
export type AlertVariant = 'info' | 'warning' | 'error' | 'critical';
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
variant: AlertVariant;
|
||||
message: React.ReactNode;
|
||||
dismissable: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
createdAt: Date;
|
||||
onClick?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface AlertStoreState {
|
||||
alerts: Alert[];
|
||||
currentIndex: number;
|
||||
}
|
||||
|
||||
type AlertStoreListener = (state: AlertStoreState) => void;
|
||||
|
||||
export class AlertStore {
|
||||
private listeners = new Set<AlertStoreListener>();
|
||||
private cache: AlertStoreState = {
|
||||
alerts: [],
|
||||
currentIndex: 0,
|
||||
};
|
||||
|
||||
getSnapshot(): AlertStoreState {
|
||||
return { ...this.cache, alerts: [...this.cache.alerts] };
|
||||
}
|
||||
|
||||
subscribe(listener: AlertStoreListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getSnapshot());
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new alert to the store
|
||||
* @param alert - Alert object (without id and createdAt)
|
||||
* @returns The ID of the created alert
|
||||
*/
|
||||
addAlert(alert: Omit<Alert, 'id' | 'createdAt'>): string {
|
||||
const id = `alert-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const newAlert: Alert = {
|
||||
...alert,
|
||||
id,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.cache.alerts.push(newAlert);
|
||||
logger.info('AlertStore', `Alert added: ${id}`, newAlert);
|
||||
this.emit(this.getSnapshot());
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing alert
|
||||
*/
|
||||
updateAlert(id: string, updates: Partial<Omit<Alert, 'id' | 'createdAt'>>): boolean {
|
||||
const index = this.cache.alerts.findIndex((a) => a.id === id);
|
||||
if (index === -1) {
|
||||
logger.warn('AlertStore', `Alert not found: ${id}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.cache.alerts[index] = {
|
||||
...this.cache.alerts[index],
|
||||
...updates,
|
||||
};
|
||||
|
||||
logger.info('AlertStore', `Alert updated: ${id}`, updates);
|
||||
this.emit(this.getSnapshot());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an alert by ID
|
||||
*/
|
||||
removeAlert(id: string): boolean {
|
||||
const initialLength = this.cache.alerts.length;
|
||||
this.cache.alerts = this.cache.alerts.filter((a) => a.id !== id);
|
||||
|
||||
if (this.cache.alerts.length < initialLength) {
|
||||
|
||||
// Adjust current index if needed
|
||||
if (this.cache.currentIndex >= this.cache.alerts.length && this.cache.alerts.length > 0) {
|
||||
this.cache.currentIndex = this.cache.alerts.length - 1;
|
||||
} else if (this.cache.alerts.length === 0) {
|
||||
this.cache.currentIndex = 0;
|
||||
}
|
||||
|
||||
logger.info('AlertStore', `Alert removed: ${id}`);
|
||||
this.emit(this.getSnapshot());
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all alerts
|
||||
*/
|
||||
clearAll(): void {
|
||||
this.cache.alerts = [];
|
||||
this.cache.currentIndex = 0;
|
||||
logger.info('AlertStore', 'All alerts cleared');
|
||||
this.emit(this.getSnapshot());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all dismissable alerts
|
||||
*/
|
||||
clearDismissable(): void {
|
||||
const initialLength = this.cache.alerts.length;
|
||||
this.cache.alerts = this.cache.alerts.filter((a) => !a.dismissable);
|
||||
|
||||
if (this.cache.alerts.length < initialLength) {
|
||||
if (this.cache.currentIndex >= this.cache.alerts.length && this.cache.alerts.length > 0) {
|
||||
this.cache.currentIndex = this.cache.alerts.length - 1;
|
||||
} else if (this.cache.alerts.length === 0) {
|
||||
this.cache.currentIndex = 0;
|
||||
}
|
||||
|
||||
logger.info('AlertStore', 'Dismissable alerts cleared');
|
||||
this.emit(this.getSnapshot());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the next alert
|
||||
*/
|
||||
nextAlert(): void {
|
||||
if (this.cache.alerts.length === 0) return;
|
||||
|
||||
this.cache.currentIndex = (this.cache.currentIndex + 1) % this.cache.alerts.length;
|
||||
this.emit(this.getSnapshot());
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the previous alert
|
||||
*/
|
||||
previousAlert(): void {
|
||||
if (this.cache.alerts.length === 0) return;
|
||||
|
||||
this.cache.currentIndex =
|
||||
this.cache.currentIndex === 0
|
||||
? this.cache.alerts.length - 1
|
||||
: this.cache.currentIndex - 1;
|
||||
this.emit(this.getSnapshot());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current alert index
|
||||
*/
|
||||
setCurrentIndex(index: number): void {
|
||||
if (index < 0 || index >= this.cache.alerts.length) {
|
||||
logger.warn('AlertStore', `Invalid index: ${index}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.cache.currentIndex = index;
|
||||
this.emit(this.getSnapshot());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently displayed alert
|
||||
*/
|
||||
getCurrentAlert(): Alert | null {
|
||||
if (this.cache.alerts.length === 0) return null;
|
||||
return this.cache.alerts[this.cache.currentIndex] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any alerts
|
||||
*/
|
||||
hasAlerts(): boolean {
|
||||
return this.cache.alerts.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of alerts
|
||||
*/
|
||||
getAlertCount(): number {
|
||||
return this.cache.alerts.length;
|
||||
}
|
||||
|
||||
private emit(state: AlertStoreState): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(state);
|
||||
} catch (error) {
|
||||
logger.error('AlertStore', 'Listener error', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const alertStore = registerSingleton('alert', () => new AlertStore());
|
||||
16
src/stores/avatars-store.ts
Normal file
16
src/stores/avatars-store.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ResourceStore } from './resource-store';
|
||||
import { VRChatService } from '@/services/vrchat';
|
||||
import type { LimitedAvatar } from '@/types/bindings';
|
||||
import { registerSingleton } from './singleton-registry';
|
||||
|
||||
class AvatarsStore extends ResourceStore<LimitedAvatar[]> {
|
||||
constructor() {
|
||||
super({ staleTime: 5 * 60_000, storeName: 'avatars' });
|
||||
}
|
||||
protected async load(_scopeId: string | null): Promise<LimitedAvatar[]> {
|
||||
return VRChatService.getUploadedAvatars();
|
||||
}
|
||||
}
|
||||
|
||||
export const avatarsStore = registerSingleton('avatars', () => new AvatarsStore());
|
||||
|
||||
73
src/stores/developer-mode-store.ts
Normal file
73
src/stores/developer-mode-store.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { SettingsService } from '@/services/settings';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { registerSingleton } from './singleton-registry';
|
||||
|
||||
type DeveloperModeListener = (value: boolean) => void;
|
||||
|
||||
export class DeveloperModeStore {
|
||||
private listeners = new Set<DeveloperModeListener>();
|
||||
private cache: boolean | null = null;
|
||||
private inflight: Promise<boolean> | null = null;
|
||||
|
||||
getSnapshot(): boolean | null {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
subscribe(listener: DeveloperModeListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.cache ?? false);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async ensure(): Promise<boolean> {
|
||||
if (this.cache !== null) {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
if (this.inflight) {
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
this.inflight = SettingsService.getDeveloperMode()
|
||||
.then((value) => {
|
||||
this.set(value);
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
this.inflight = null;
|
||||
});
|
||||
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
async refresh(): Promise<boolean> {
|
||||
this.cache = null;
|
||||
return this.ensure();
|
||||
}
|
||||
|
||||
set(value: boolean): void {
|
||||
this.cache = value;
|
||||
this.emit(value);
|
||||
}
|
||||
|
||||
async toggle(): Promise<boolean> {
|
||||
const newValue = !this.cache;
|
||||
await SettingsService.setDeveloperMode(newValue);
|
||||
this.set(newValue);
|
||||
return newValue;
|
||||
}
|
||||
|
||||
private emit(value: boolean): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(value);
|
||||
} catch (error) {
|
||||
logger.error('DeveloperModeStore', 'DeveloperModeStore listener error', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const developerModeStore = registerSingleton('developerMode', () => new DeveloperModeStore());
|
||||
7
src/stores/index.ts
Normal file
7
src/stores/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './user-store';
|
||||
export * from './worlds-store';
|
||||
export * from './avatars-store';
|
||||
export * from './accounts-store';
|
||||
export * from './developer-mode-store';
|
||||
export * from './vrchat-status-store';
|
||||
export * from './alert-store';
|
||||
277
src/stores/resource-store.ts
Normal file
277
src/stores/resource-store.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getActiveAccountId, subscribeActiveAccount } from './account-scope';
|
||||
|
||||
type Listener<T> = (value: T | null) => void;
|
||||
|
||||
export interface ResourceStoreOptions {
|
||||
staleTime?: number;
|
||||
storeName?: string;
|
||||
}
|
||||
|
||||
interface StoreState<T> {
|
||||
cache: T | null;
|
||||
inflight: Promise<T> | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface EnsureOptions {
|
||||
force?: boolean;
|
||||
scopeId?: string | null;
|
||||
}
|
||||
|
||||
interface SetOptions {
|
||||
stale?: boolean;
|
||||
scopeId?: string | null;
|
||||
}
|
||||
|
||||
interface ClearOptions {
|
||||
scopeId?: string | null;
|
||||
}
|
||||
|
||||
interface MarkStaleOptions {
|
||||
scopeId?: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_SCOPE_KEY = '__no_active_account__';
|
||||
|
||||
export interface ResourceStoreDebugScope<T> {
|
||||
scopeId: string | null;
|
||||
cache: T | null;
|
||||
updatedAt: number;
|
||||
ageMs: number | null;
|
||||
stale: boolean;
|
||||
inflight: boolean;
|
||||
isActiveScope: boolean;
|
||||
}
|
||||
|
||||
function scopeKey(scopeId: string | null): string {
|
||||
return scopeId ?? DEFAULT_SCOPE_KEY;
|
||||
}
|
||||
|
||||
export abstract class ResourceStore<T> {
|
||||
private listeners = new Set<Listener<T>>();
|
||||
private states = new Map<string, StoreState<T>>();
|
||||
private activeAccountId: string | null = getActiveAccountId();
|
||||
protected staleTime: number;
|
||||
private instanceId = Math.random().toString(36).substring(2, 11);
|
||||
private storeName: string;
|
||||
|
||||
constructor(options: ResourceStoreOptions = {}) {
|
||||
this.staleTime = options.staleTime ?? 60_000;
|
||||
this.storeName = options.storeName ?? this.constructor.name;
|
||||
|
||||
logger.info('ResourceStore', `Created instance: ${this.instanceId} (${this.storeName})`);
|
||||
|
||||
subscribeActiveAccount((accountId) => {
|
||||
this.activeAccountId = accountId;
|
||||
this.emit(this.getSnapshot());
|
||||
});
|
||||
}
|
||||
|
||||
protected abstract load(scopeId: string | null): Promise<T>;
|
||||
|
||||
/**
|
||||
* Return the cached value without triggering a fetch.
|
||||
*/
|
||||
getSnapshot(scopeId: string | null = this.activeAccountId): T | null {
|
||||
const cache = this.getState(scopeId).cache;
|
||||
logger.debug('ResourceStore.getSnapshot', this.storeName, {
|
||||
instanceId: this.instanceId,
|
||||
scopeId,
|
||||
activeAccountId: this.activeAccountId,
|
||||
hasCache: cache !== null,
|
||||
allStates: Array.from(this.states.keys()),
|
||||
});
|
||||
return cache;
|
||||
}
|
||||
|
||||
protected getCurrentScopeId(): string | null {
|
||||
return this.activeAccountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to changes. The listener is invoked immediately with the current snapshot.
|
||||
*/
|
||||
subscribe(listener: Listener<T>): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getSnapshot());
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind this store to a React state setter
|
||||
*/
|
||||
bind(setter: Dispatch<SetStateAction<T | null>>): () => void {
|
||||
return this.subscribe((value) => setter(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a fresh value exists, respecting the configured stale time unless forced.
|
||||
*/
|
||||
async ensure(options: EnsureOptions = {}): Promise<T> {
|
||||
const hasExplicitScope = Object.prototype.hasOwnProperty.call(options, 'scopeId');
|
||||
const targetScopeId = hasExplicitScope ? options.scopeId ?? null : this.activeAccountId;
|
||||
const state = this.getState(targetScopeId);
|
||||
const force = options.force ?? false;
|
||||
|
||||
if (!force && state.cache && !this.isStale(state)) {
|
||||
return state.cache;
|
||||
}
|
||||
|
||||
if (state.inflight) {
|
||||
return state.inflight;
|
||||
}
|
||||
|
||||
const inflight = this.load(targetScopeId)
|
||||
.then((value) => {
|
||||
this.set(value, { scopeId: targetScopeId });
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
state.inflight = null;
|
||||
});
|
||||
|
||||
state.inflight = inflight;
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a refresh regardless of stale time.
|
||||
*/
|
||||
refresh(): Promise<T> {
|
||||
return this.ensure({ force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a new value into the cache and notify listeners.
|
||||
*/
|
||||
set(value: T | null, options: SetOptions = {}): void {
|
||||
const scopeId = options.scopeId ?? this.activeAccountId;
|
||||
const state = this.getState(scopeId);
|
||||
|
||||
logger.debug('ResourceStore.set', this.storeName, {
|
||||
instanceId: this.instanceId,
|
||||
value: value !== null ? '(data)' : 'null',
|
||||
scopeId,
|
||||
activeAccountId: this.activeAccountId,
|
||||
willEmit: scopeId === this.activeAccountId,
|
||||
});
|
||||
|
||||
state.cache = value;
|
||||
state.updatedAt = value && !options.stale ? Date.now() : 0;
|
||||
|
||||
// Emit to local listeners if this is the active scope
|
||||
if (scopeId === this.activeAccountId) {
|
||||
this.emit(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cache and notify listeners.
|
||||
*/
|
||||
clear(options: ClearOptions = {}): void {
|
||||
const scopeId = options.scopeId ?? this.activeAccountId;
|
||||
this.set(null, { scopeId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current cached value as stale without clearing it.
|
||||
*/
|
||||
markStale(options: MarkStaleOptions = {}): void {
|
||||
const scopeId = options.scopeId ?? this.activeAccountId;
|
||||
const state = this.getState(scopeId);
|
||||
if (state.cache) {
|
||||
state.updatedAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal debug helper
|
||||
*/
|
||||
debugScopes(): ResourceStoreDebugScope<T>[] {
|
||||
const scopes: ResourceStoreDebugScope<T>[] = [];
|
||||
|
||||
for (const [rawKey, state] of this.states.entries()) {
|
||||
const scopeId = rawKey === DEFAULT_SCOPE_KEY ? null : rawKey;
|
||||
const isActiveScope =
|
||||
scopeId === this.activeAccountId ||
|
||||
(scopeId === null && this.activeAccountId === null);
|
||||
const updatedAt = state.updatedAt;
|
||||
const ageMs = updatedAt === 0 ? null : Date.now() - updatedAt;
|
||||
|
||||
scopes.push({
|
||||
scopeId,
|
||||
cache: state.cache,
|
||||
updatedAt,
|
||||
ageMs,
|
||||
stale: this.isStale(state),
|
||||
inflight: state.inflight !== null,
|
||||
isActiveScope,
|
||||
});
|
||||
}
|
||||
|
||||
const activeKey = scopeKey(this.activeAccountId);
|
||||
if (!this.states.has(activeKey)) {
|
||||
scopes.push({
|
||||
scopeId: this.activeAccountId ?? null,
|
||||
cache: null,
|
||||
updatedAt: 0,
|
||||
ageMs: null,
|
||||
stale: true,
|
||||
inflight: false,
|
||||
isActiveScope: true,
|
||||
});
|
||||
}
|
||||
|
||||
scopes.sort((a, b) => {
|
||||
if (a.isActiveScope && !b.isActiveScope) return -1;
|
||||
if (!a.isActiveScope && b.isActiveScope) return 1;
|
||||
const nameA = a.scopeId ?? '';
|
||||
const nameB = b.scopeId ?? '';
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
private isStale(state: StoreState<T>): boolean {
|
||||
if (!state.cache) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - state.updatedAt > this.staleTime;
|
||||
}
|
||||
|
||||
private emit(value: T | null): void {
|
||||
logger.debug('ResourceStore.emit', this.storeName, {
|
||||
value: value !== null ? '(data)' : 'null',
|
||||
listenerCount: this.listeners.size,
|
||||
});
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(value);
|
||||
} catch (error) {
|
||||
logger.error('ResourceStore', 'ResourceStore listener error', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getState(scopeId: string | null): StoreState<T> {
|
||||
const key = scopeKey(scopeId);
|
||||
const existing = this.states.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const fresh: StoreState<T> = {
|
||||
cache: null,
|
||||
inflight: null,
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
this.states.set(key, fresh);
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
56
src/stores/singleton-registry.ts
Normal file
56
src/stores/singleton-registry.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ResourceStore } from './resource-store';
|
||||
import type { AccountsStore } from './accounts-store';
|
||||
import type { DeveloperModeStore } from './developer-mode-store';
|
||||
import type { VRChatStatusStore } from './vrchat-status-store';
|
||||
import type { AlertStore } from './alert-store';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
/**
|
||||
* Global singleton registry to force single instances across all imports
|
||||
* This prevents Vite/bundler from creating duplicate instances due to different import paths
|
||||
* Also ensures singletons survive HMR
|
||||
*/
|
||||
|
||||
// Use globalThis to ensure singleton survives HMR
|
||||
declare global {
|
||||
var __STORE_SINGLETONS__: {
|
||||
user?: ResourceStore<any>;
|
||||
worlds?: ResourceStore<any>;
|
||||
avatars?: ResourceStore<any>;
|
||||
accounts?: AccountsStore;
|
||||
developerMode?: DeveloperModeStore;
|
||||
vrchatStatus?: VRChatStatusStore;
|
||||
alert?: AlertStore;
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
if (!globalThis.__STORE_SINGLETONS__) {
|
||||
globalThis.__STORE_SINGLETONS__ = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a singleton store instance
|
||||
* If the store already exists, returns the existing instance
|
||||
* Otherwise, creates a new instance using the factory function
|
||||
*/
|
||||
export function registerSingleton<T>(key: string, factory: () => T): T {
|
||||
const registry = globalThis.__STORE_SINGLETONS__ as any;
|
||||
|
||||
if (!registry[key]) {
|
||||
logger.info('SingletonRegistry', `Creating singleton: ${key}`);
|
||||
registry[key] = factory();
|
||||
} else {
|
||||
logger.debug('SingletonRegistry', `Reusing existing singleton: ${key}`);
|
||||
}
|
||||
|
||||
return registry[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing singleton by key
|
||||
* Returns undefined if the singleton doesn't exist
|
||||
*/
|
||||
export function getSingleton<T>(key: string): T | undefined {
|
||||
const registry = globalThis.__STORE_SINGLETONS__ as any;
|
||||
return registry[key];
|
||||
}
|
||||
242
src/stores/user-store.ts
Normal file
242
src/stores/user-store.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { ResourceStore, type ResourceStoreDebugScope } from './resource-store';
|
||||
import { setActiveAccountId, subscribeActiveAccount } from './account-scope';
|
||||
import { VRChatService } from '@/services/vrchat';
|
||||
import type { LimitedUserFriend, User } from '@/types/bindings';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { registerSingleton } from './singleton-registry';
|
||||
|
||||
type FriendListener = (value: LimitedUserFriend[] | null) => void;
|
||||
|
||||
interface FriendsState {
|
||||
cache: LimitedUserFriend[] | null;
|
||||
inflight: Promise<LimitedUserFriend[]> | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface FriendsEnsureOptions {
|
||||
force?: boolean;
|
||||
scopeId?: string | null;
|
||||
}
|
||||
|
||||
interface FriendsSetOptions {
|
||||
stale?: boolean;
|
||||
scopeId?: string | null;
|
||||
}
|
||||
|
||||
const FRIEND_SCOPE_DEFAULT = '__no_active_account__';
|
||||
|
||||
function friendsScopeKey(scopeId: string | null): string {
|
||||
return scopeId ?? FRIEND_SCOPE_DEFAULT;
|
||||
}
|
||||
|
||||
class UserStore extends ResourceStore<User> {
|
||||
private readonly friendsStaleTime = 30_000;
|
||||
private friendListeners = new Set<FriendListener>();
|
||||
private friendStates = new Map<string, FriendsState>();
|
||||
|
||||
constructor(options: { staleTime?: number } = {}) {
|
||||
super({ ...options, storeName: 'user' });
|
||||
|
||||
subscribeActiveAccount(() => {
|
||||
this.emitFriends(this.getFriendsSnapshot());
|
||||
});
|
||||
}
|
||||
|
||||
protected async load(scopeId: string | null): Promise<User> {
|
||||
const user = await VRChatService.getCurrentUser();
|
||||
if (scopeId !== user.id) {
|
||||
setActiveAccountId(user.id);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
set(value: User | null, options: { stale?: boolean; scopeId?: string | null } = {}): void {
|
||||
const scopeId =
|
||||
options.scopeId !== undefined
|
||||
? options.scopeId
|
||||
: value
|
||||
? value.id
|
||||
: this.getCurrentScopeId();
|
||||
|
||||
// Only set active account ID if we're setting the current scope or if scopeId is explicitly provided
|
||||
if (options.scopeId === undefined && value && scopeId === this.getCurrentScopeId()) {
|
||||
setActiveAccountId(scopeId);
|
||||
}
|
||||
|
||||
super.set(value, { ...options, scopeId });
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Friends Management
|
||||
// ===========================================================================
|
||||
|
||||
getFriendsSnapshot(scopeId: string | null = this.getCurrentScopeId()): LimitedUserFriend[] | null {
|
||||
const state = this.friendStates.get(friendsScopeKey(scopeId));
|
||||
return state ? state.cache : null;
|
||||
}
|
||||
|
||||
subscribeFriends(listener: FriendListener): () => void {
|
||||
this.friendListeners.add(listener);
|
||||
listener(this.getFriendsSnapshot());
|
||||
return () => {
|
||||
this.friendListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async ensureFriends(options: FriendsEnsureOptions = {}): Promise<LimitedUserFriend[]> {
|
||||
const hasExplicitScope = Object.prototype.hasOwnProperty.call(options, 'scopeId');
|
||||
const targetScopeId = hasExplicitScope ? options.scopeId ?? null : this.getCurrentScopeId();
|
||||
const state = this.getOrCreateFriendsState(targetScopeId);
|
||||
const force = options.force ?? false;
|
||||
|
||||
if (!force && state.cache && !this.isFriendsStale(state)) {
|
||||
return state.cache;
|
||||
}
|
||||
|
||||
if (state.inflight) {
|
||||
return state.inflight;
|
||||
}
|
||||
|
||||
const inflight = VRChatService.getOnlineFriends()
|
||||
.then((friends) => {
|
||||
this.setFriends(friends, { scopeId: targetScopeId });
|
||||
return friends;
|
||||
})
|
||||
.finally(() => {
|
||||
state.inflight = null;
|
||||
});
|
||||
|
||||
state.inflight = inflight;
|
||||
return inflight;
|
||||
}
|
||||
|
||||
refreshFriends(options: FriendsEnsureOptions = {}): Promise<LimitedUserFriend[]> {
|
||||
return this.ensureFriends({ ...options, force: true });
|
||||
}
|
||||
|
||||
setFriends(
|
||||
value: LimitedUserFriend[] | null,
|
||||
options: FriendsSetOptions = {},
|
||||
): void {
|
||||
const scopeId = options.scopeId ?? this.getCurrentScopeId();
|
||||
const state = this.getOrCreateFriendsState(scopeId);
|
||||
|
||||
state.cache = value;
|
||||
state.updatedAt = value && !options.stale ? Date.now() : 0;
|
||||
|
||||
if (friendsScopeKey(scopeId) === friendsScopeKey(this.getCurrentScopeId())) {
|
||||
this.emitFriends(value);
|
||||
}
|
||||
}
|
||||
|
||||
clearFriends(options: { scopeId?: string | null } = {}): void {
|
||||
const scopeId = options.scopeId ?? this.getCurrentScopeId();
|
||||
const key = friendsScopeKey(scopeId);
|
||||
const state = this.friendStates.get(key);
|
||||
|
||||
if (state) {
|
||||
state.cache = null;
|
||||
state.updatedAt = 0;
|
||||
state.inflight = null;
|
||||
} else {
|
||||
this.friendStates.set(key, {
|
||||
cache: null,
|
||||
inflight: null,
|
||||
updatedAt: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (key === friendsScopeKey(this.getCurrentScopeId())) {
|
||||
this.emitFriends(null);
|
||||
}
|
||||
}
|
||||
|
||||
markFriendsStale(options: { scopeId?: string | null } = {}): void {
|
||||
const scopeId = options.scopeId ?? this.getCurrentScopeId();
|
||||
const state = this.friendStates.get(friendsScopeKey(scopeId));
|
||||
if (state && state.cache) {
|
||||
state.updatedAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
debugFriendScopes(): ResourceStoreDebugScope<LimitedUserFriend[]>[] {
|
||||
const scopes: ResourceStoreDebugScope<LimitedUserFriend[]>[] = [];
|
||||
const activeKey = friendsScopeKey(this.getCurrentScopeId());
|
||||
|
||||
for (const [key, state] of this.friendStates.entries()) {
|
||||
const scopeId = key === FRIEND_SCOPE_DEFAULT ? null : key;
|
||||
const ageMs = state.updatedAt === 0 ? null : Date.now() - state.updatedAt;
|
||||
scopes.push({
|
||||
scopeId,
|
||||
cache: state.cache,
|
||||
updatedAt: state.updatedAt,
|
||||
ageMs,
|
||||
stale: this.isFriendsStale(state),
|
||||
inflight: state.inflight !== null,
|
||||
isActiveScope: key === activeKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.friendStates.has(activeKey)) {
|
||||
scopes.push({
|
||||
scopeId: this.getCurrentScopeId(),
|
||||
cache: null,
|
||||
updatedAt: 0,
|
||||
ageMs: null,
|
||||
stale: true,
|
||||
inflight: false,
|
||||
isActiveScope: true,
|
||||
});
|
||||
}
|
||||
|
||||
scopes.sort((a, b) => {
|
||||
if (a.isActiveScope && !b.isActiveScope) return -1;
|
||||
if (!a.isActiveScope && b.isActiveScope) return 1;
|
||||
const nameA = a.scopeId ?? '';
|
||||
const nameB = b.scopeId ?? '';
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
private getOrCreateFriendsState(scopeId: string | null): FriendsState {
|
||||
const key = friendsScopeKey(scopeId);
|
||||
const existing = this.friendStates.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const fresh: FriendsState = {
|
||||
cache: null,
|
||||
inflight: null,
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
this.friendStates.set(key, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private isFriendsStale(state: FriendsState): boolean {
|
||||
if (!state.cache) {
|
||||
return true;
|
||||
}
|
||||
return Date.now() - state.updatedAt > this.friendsStaleTime;
|
||||
}
|
||||
|
||||
private emitFriends(value: LimitedUserFriend[] | null): void {
|
||||
for (const listener of this.friendListeners) {
|
||||
try {
|
||||
listener(value);
|
||||
} catch (error) {
|
||||
logger.error('UserStore', 'UserStore friends listener error', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const userStore = registerSingleton('user', () => new UserStore());
|
||||
|
||||
// NOTE: Store Studio attachment is handled by `singleton-registry`. Do not
|
||||
// attempt to read from `window.opener` or create new instances here.
|
||||
240
src/stores/vrchat-status-store.ts
Normal file
240
src/stores/vrchat-status-store.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { commands } from "@/types/bindings";
|
||||
import type {
|
||||
VRChatStatusResponse,
|
||||
SystemStatus,
|
||||
StatusIndicator,
|
||||
} from "@/types/bindings";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { alertStore } from "./alert-store";
|
||||
import type { AlertVariant } from "./alert-store";
|
||||
import { registerSingleton } from "./singleton-registry";
|
||||
|
||||
export interface VRChatStatusState {
|
||||
status: SystemStatus | null;
|
||||
statusPageUrl: string | null;
|
||||
lastUpdated: Date | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type VRChatStatusListener = (state: VRChatStatusState) => void;
|
||||
|
||||
export class VRChatStatusStore {
|
||||
private listeners = new Set<VRChatStatusListener>();
|
||||
private cache: VRChatStatusState = {
|
||||
status: null,
|
||||
statusPageUrl: null,
|
||||
lastUpdated: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
private pollInterval: number | null = null;
|
||||
private pollIntervalMs: number = 300000; // 5 minutes
|
||||
private currentAlertId: string | null = null;
|
||||
|
||||
getSnapshot(): VRChatStatusState {
|
||||
return { ...this.cache };
|
||||
}
|
||||
|
||||
subscribe(listener: VRChatStatusListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
listener(this.getSnapshot());
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for VRChat status
|
||||
* @param intervalMs - Polling interval in milliseconds
|
||||
*/
|
||||
startPolling(intervalMs: number = 300000): void {
|
||||
this.pollIntervalMs = intervalMs;
|
||||
|
||||
this.fetchStatus();
|
||||
|
||||
if (this.pollInterval !== null) {
|
||||
clearInterval(this.pollInterval);
|
||||
}
|
||||
|
||||
this.pollInterval = window.setInterval(() => {
|
||||
this.fetchStatus();
|
||||
}, this.pollIntervalMs);
|
||||
|
||||
logger.info("VRChatStatusStore", `Started polling every ${intervalMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling for VRChat status
|
||||
*/
|
||||
stopPolling(): void {
|
||||
if (this.pollInterval !== null) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
logger.info("VRChatStatusStore", "Stopped polling");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually fetch VRChat status
|
||||
*/
|
||||
async fetchStatus(): Promise<void> {
|
||||
this.updateState({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
logger.debug("VRChatStatusStore", "Fetching VRChat status...");
|
||||
const result = await commands.getVrchatStatus();
|
||||
|
||||
if (result.status === "ok") {
|
||||
const response = result.data as VRChatStatusResponse;
|
||||
logger.debug("VRChatStatusStore", "Status received:", response.status);
|
||||
|
||||
this.updateState({
|
||||
status: response.status,
|
||||
statusPageUrl: response.page.url,
|
||||
lastUpdated: new Date(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
this.syncToAlertStore();
|
||||
} else {
|
||||
logger.error("VRChatStatusStore", "Error response:", result.error);
|
||||
this.updateState({
|
||||
isLoading: false,
|
||||
error: result.error || "Failed to fetch VRChat status",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Unknown error occurred";
|
||||
logger.error("VRChatStatusStore", "Exception:", err);
|
||||
this.updateState({
|
||||
isLoading: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if VRChat services are experiencing issues
|
||||
*/
|
||||
hasIssues(): boolean {
|
||||
return this.cache.status !== null && this.cache.status.indicator !== "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status indicator
|
||||
*/
|
||||
getSeverityLevel(): StatusIndicator {
|
||||
return this.cache.status?.indicator ?? "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data
|
||||
*/
|
||||
clear(): void {
|
||||
this.updateState({
|
||||
status: null,
|
||||
statusPageUrl: null,
|
||||
lastUpdated: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Remove alert from Alert Store
|
||||
if (this.currentAlertId) {
|
||||
alertStore.removeAlert(this.currentAlertId);
|
||||
this.currentAlertId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync VRChat status to the Alert Store
|
||||
*/
|
||||
private syncToAlertStore(): void {
|
||||
const { status, statusPageUrl, lastUpdated } = this.cache;
|
||||
|
||||
// Remove existing alert if status is now healthy
|
||||
if (!status || status.indicator === "none") {
|
||||
if (this.currentAlertId) {
|
||||
alertStore.removeAlert(this.currentAlertId);
|
||||
this.currentAlertId = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Map status indicator to alert variant
|
||||
const variant: AlertVariant =
|
||||
status.indicator === "critical"
|
||||
? "critical"
|
||||
: status.indicator === "major"
|
||||
? "error"
|
||||
: status.indicator === "minor"
|
||||
? "warning"
|
||||
: "info";
|
||||
|
||||
const message = `VRChat Status: ${status.description}`;
|
||||
|
||||
const onClick = statusPageUrl
|
||||
? async () => {
|
||||
try {
|
||||
const opener = await import("@tauri-apps/plugin-opener");
|
||||
const anyOpener = opener as any;
|
||||
if (typeof anyOpener.openUrl === "function") {
|
||||
await anyOpener.openUrl(statusPageUrl);
|
||||
} else if (anyOpener && typeof anyOpener.default === "object") {
|
||||
const defaultOpener = anyOpener.default;
|
||||
if (typeof defaultOpener.openUrl === "function") {
|
||||
await defaultOpener.openUrl(statusPageUrl);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"VRChatStatusStore",
|
||||
"Failed to open status page:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Update existing alert or create new one
|
||||
if (this.currentAlertId) {
|
||||
alertStore.updateAlert(this.currentAlertId, {
|
||||
variant,
|
||||
message,
|
||||
onClick,
|
||||
metadata: { status, statusPageUrl, lastUpdated },
|
||||
});
|
||||
} else {
|
||||
this.currentAlertId = alertStore.addAlert({
|
||||
variant,
|
||||
message,
|
||||
dismissable: false,
|
||||
onClick,
|
||||
metadata: { status, statusPageUrl, lastUpdated },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateState(partial: Partial<VRChatStatusState>): void {
|
||||
this.cache = { ...this.cache, ...partial };
|
||||
this.emit(this.getSnapshot());
|
||||
}
|
||||
|
||||
private emit(state: VRChatStatusState): void {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(state);
|
||||
} catch (error) {
|
||||
logger.error("VRChatStatusStore", "Listener error", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const vrchatStatusStore = registerSingleton(
|
||||
"vrchatStatus",
|
||||
() => new VRChatStatusStore()
|
||||
);
|
||||
15
src/stores/worlds-store.ts
Normal file
15
src/stores/worlds-store.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ResourceStore } from './resource-store';
|
||||
import { VRChatService } from '@/services/vrchat';
|
||||
import type { LimitedWorld } from '@/types/bindings';
|
||||
import { registerSingleton } from './singleton-registry';
|
||||
|
||||
class WorldsStore extends ResourceStore<LimitedWorld[]> {
|
||||
constructor() {
|
||||
super({ staleTime: 5 * 60_000, storeName: 'worlds' });
|
||||
}
|
||||
protected async load(_scopeId: string | null): Promise<LimitedWorld[]> {
|
||||
return VRChatService.getUploadedWorlds();
|
||||
}
|
||||
}
|
||||
|
||||
export const worldsStore = registerSingleton('worlds', () => new WorldsStore());
|
||||
626
src/types/bindings.ts
Normal file
626
src/types/bindings.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
// @ts-nocheck
|
||||
// This file is auto-generated by Specta. Do not edit manually.
|
||||
|
||||
|
||||
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
|
||||
|
||||
/** user-defined commands **/
|
||||
|
||||
|
||||
export const commands = {
|
||||
async vrchatLogin(email: string, password: string) : Promise<Result<LoginResult, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("vrchat_login", { email, password }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async vrchatVerify2fa(code: string, method: string) : Promise<Result<boolean, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("vrchat_verify_2fa", { code, method }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async vrchatGetCurrentUser() : Promise<Result<User, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("vrchat_get_current_user") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async vrchatUpdateStatus(status: UserStatus, statusDescription: string) : Promise<Result<User, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("vrchat_update_status", { status, statusDescription }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async vrchatLogout() : Promise<Result<null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("vrchat_logout") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async vrchatGetOnlineFriends() : Promise<Result<LimitedUserFriend[], VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("vrchat_get_online_friends") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async vrchatGetUploadedWorlds() : Promise<Result<LimitedWorld[], VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("vrchat_get_uploaded_worlds") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async vrchatGetUploadedAvatars() : Promise<Result<LimitedAvatar[], VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("vrchat_get_uploaded_avatars") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getOnlineFriends() : Promise<Result<LimitedUserFriend[], VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_online_friends") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getAllFriends() : Promise<Result<LimitedUserFriend[], VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_all_friends") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getUser(userId: string) : Promise<Result<LimitedUserFriend | null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_user", { userId }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getUserById(userId: string) : Promise<Result<User, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_user_by_id", { userId }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async isFriend(userId: string) : Promise<Result<boolean, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("is_friend", { userId }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async isUserOnline(userId: string) : Promise<Result<boolean, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("is_user_online", { userId }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async vrchatCheckSession() : Promise<Result<boolean, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("vrchat_check_session") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async vrchatClearSession() : Promise<Result<null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("vrchat_clear_session") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async websocketStart() : Promise<Result<null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("websocket_start") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async websocketStop() : Promise<Result<null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("websocket_stop") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async saveCurrentAccount(user: User) : Promise<Result<null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("save_current_account", { user }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getAllAccounts() : Promise<Result<StoredAccount[], VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_all_accounts") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async switchAccount(userId: string) : Promise<Result<User, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("switch_account", { userId }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async removeAccount(userId: string) : Promise<Result<null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("remove_account", { userId }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async clearAllAccounts() : Promise<Result<null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("clear_all_accounts") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async loadLastAccount() : Promise<Result<User | null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("load_last_account") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getSettings() : Promise<Result<AppSettings, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_settings") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async saveSettings(settings: AppSettings) : Promise<Result<null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("save_settings", { settings }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getDeveloperMode() : Promise<Result<boolean, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_developer_mode") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async setDeveloperMode(enabled: boolean) : Promise<Result<null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("set_developer_mode", { enabled }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getBackendLogs() : Promise<Result<LogEntry[], VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_backend_logs") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async clearBackendLogs() : Promise<Result<null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("clear_backend_logs") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async exportBackendLogs() : Promise<Result<string, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("export_backend_logs") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getVrchatStatus() : Promise<Result<VRChatStatusResponse, string>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_vrchat_status") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async dbListTables() : Promise<Result<TableInfo[], VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("db_list_tables") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async dbGetTableSchema(tableName: string) : Promise<Result<ColumnInfo[], VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("db_get_table_schema", { tableName }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async dbGetTableData(tableName: string, limit: number | null, offset: number | null) : Promise<Result<QueryResult, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("db_get_table_data", { tableName, limit, offset }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async dbGetTableCount(tableName: string) : Promise<Result<number, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("db_get_table_count", { tableName }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async dbExecuteQuery(query: string) : Promise<Result<QueryResult, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("db_execute_query", { query }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async checkImageCached(url: string) : Promise<Result<string | null, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("check_image_cached", { url }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async cacheImage(url: string) : Promise<Result<string, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("cache_image", { url }) };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
async getCacheDirectory() : Promise<Result<string, VRCError>> {
|
||||
try {
|
||||
return { status: "ok", data: await TAURI_INVOKE("get_cache_directory") };
|
||||
} catch (e) {
|
||||
if(e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** user-defined events **/
|
||||
|
||||
|
||||
|
||||
/** user-defined constants **/
|
||||
|
||||
|
||||
|
||||
/** user-defined types **/
|
||||
|
||||
/**
|
||||
* Age verification status
|
||||
* `verified` is obsolete. according to the unofficial docs, Users who have verified and are 18+ can switch to `plus18` status.
|
||||
*/
|
||||
export type AgeVerificationStatus =
|
||||
/**
|
||||
* Age verification status is hidden
|
||||
*/
|
||||
"hidden" |
|
||||
/**
|
||||
* Legacy verified status (obsolete)
|
||||
*/
|
||||
"verified" |
|
||||
/**
|
||||
* User is verified to be 18+
|
||||
*/
|
||||
"18+"
|
||||
export type AppSettings = { developer_mode: boolean }
|
||||
export type AvatarPerformance = { android?: string | null; ios?: string | null; standalonewindows?: string | null }
|
||||
export type AvatarStyles = { primary?: string | null; secondary?: string | null }
|
||||
export type Badge = { badgeId: string; badgeName?: string; badgeDescription?: string; assignedAt?: string | null; showcased?: boolean; badgeImageUrl?: string | null; updatedAt?: string | null; hidden?: boolean }
|
||||
export type ColumnInfo = { cid: number; name: string; type: string; notnull: number; dflt_value: string | null; pk: number }
|
||||
/**
|
||||
* User's developer type/staff level
|
||||
*/
|
||||
export type DeveloperType =
|
||||
/**
|
||||
* Normal user
|
||||
*/
|
||||
"none" |
|
||||
/**
|
||||
* Trusted user
|
||||
*/
|
||||
"trusted" |
|
||||
/**
|
||||
* VRChat Developer/Staff
|
||||
*/
|
||||
"internal" |
|
||||
/**
|
||||
* VRChat Moderator
|
||||
*/
|
||||
"moderator"
|
||||
export type DiscordDetails = { globalName?: string | null; id?: string | null }
|
||||
/**
|
||||
* Friend request status
|
||||
*/
|
||||
export type FriendRequestStatus =
|
||||
/**
|
||||
* No friend request
|
||||
*/
|
||||
"" |
|
||||
/**
|
||||
* Outgoing friend request pending
|
||||
*/
|
||||
"outgoing" |
|
||||
/**
|
||||
* Incoming friend request pending
|
||||
*/
|
||||
"incoming" |
|
||||
/**
|
||||
* Completed friend request
|
||||
*/
|
||||
"completed"
|
||||
export type GoogleDetails = { emailMatches?: boolean | null }
|
||||
export type LimitedAvatar = { id: string; name: string; description?: string | null; authorId?: string | null; authorName?: string | null; imageUrl?: string | null; thumbnailImageUrl?: string | null; assetUrl?: string | null; unityPackageUrl?: string | null; releaseStatus?: ReleaseStatus; featured?: boolean | null; searchable?: boolean | null; listingDate?: string | null; createdAt?: string | null; updatedAt?: string | null; version?: number | null; tags?: string[]; performance?: AvatarPerformance | null; styles?: AvatarStyles | null; unityPackages?: UnityPackageSummary[] }
|
||||
export type LimitedUserFriend = { id: string; displayName: string; bio?: string; bioLinks?: string[]; currentAvatarImageUrl?: string | null; currentAvatarThumbnailImageUrl?: string | null; currentAvatarTags?: string[]; developerType?: DeveloperType; friendKey?: string | null; isFriend?: boolean; imageUrl?: string | null; lastPlatform?: string | null; location?: string | null; lastLogin?: string | null; lastActivity?: string | null; lastMobile?: string | null; platform?: string; profilePicOverride?: string | null; profilePicOverrideThumbnail?: string | null; status?: UserStatus; statusDescription?: string; tags?: string[]; userIcon?: string | null }
|
||||
export type LimitedWorld = { id: string; name: string; description?: string | null; authorId?: string | null; authorName?: string | null; imageUrl?: string | null; thumbnailImageUrl?: string | null; releaseStatus?: ReleaseStatus; publicationDate?: string | null; createdAt?: string | null; updatedAt?: string | null; labsPublicationDate?: string | null; visits?: number | null; favorites?: number | null; popularity?: number | null; occupants?: number | null; capacity?: number | null; recommendedCapacity?: number | null; heat?: number | null; organization?: string | null; previewYoutubeId?: string | null; tags?: string[]; unityPackages?: UnityPackageSummary[] }
|
||||
export type LogEntry = { timestamp: string; level: string; source: string; module: string; message: string }
|
||||
export type LoginResult = { type: "Success"; user: User } | { type: "TwoFactorRequired"; methods: string[] }
|
||||
/**
|
||||
* Sort order for API queries
|
||||
*/
|
||||
export type OrderOption =
|
||||
/**
|
||||
* Ascending order
|
||||
*/
|
||||
"ascending" |
|
||||
/**
|
||||
* Descending order
|
||||
*/
|
||||
"descending"
|
||||
export type PastDisplayName = { displayName: string; updatedAt?: string | null; reverted?: boolean | null }
|
||||
/**
|
||||
* Avatar performance ratings
|
||||
*/
|
||||
export type PerformanceRatings =
|
||||
/**
|
||||
* No rating
|
||||
*/
|
||||
"None" |
|
||||
/**
|
||||
* Excellent performance
|
||||
*/
|
||||
"Excellent" |
|
||||
/**
|
||||
* Good performance
|
||||
*/
|
||||
"Good" |
|
||||
/**
|
||||
* Medium performance
|
||||
*/
|
||||
"Medium" |
|
||||
/**
|
||||
* Poor performance
|
||||
*/
|
||||
"Poor" |
|
||||
/**
|
||||
* Very poor performance
|
||||
*/
|
||||
"VeryPoor"
|
||||
export type QueryResult = { columns: string[]; rows: (Partial<{ [key in string]: string }>)[]; rows_affected: number | null }
|
||||
/**
|
||||
* Release status of avatars and worlds
|
||||
*/
|
||||
export type ReleaseStatus =
|
||||
/**
|
||||
* Publicly released
|
||||
*/
|
||||
"public" |
|
||||
/**
|
||||
* Private/restricted access
|
||||
*/
|
||||
"private" |
|
||||
/**
|
||||
* Hidden from listings
|
||||
*/
|
||||
"hidden" |
|
||||
/**
|
||||
* Filter for all statuses
|
||||
*/
|
||||
"all"
|
||||
export type StatusIndicator = "none" | "minor" | "major" | "critical"
|
||||
export type StatusPage = { id: string; name: string; url: string; time_zone: string; updated_at: string }
|
||||
export type SteamDetails = { avatar?: string | null; avatarfull?: string | null; avatarhash?: string | null; avatarmedium?: string | null; communityvisibilitystate?: number | null; gameextrainfo?: string | null; gameid?: string | null; loccountrycode?: string | null; locstatecode?: string | null; personaname?: string | null; personastate?: number | null; personastateflags?: number | null; primaryclanid?: string | null; profilestate?: number | null; profileurl?: string | null; steamid?: string | null; timecreated?: number | null }
|
||||
export type StoredAccount = { user_id: string; username: string; display_name: string; avatar_url?: string | null; avatar_fallback_url?: string | null; auth_cookie: string | null; two_factor_cookie: string | null; last_login: string }
|
||||
export type SystemStatus = {
|
||||
/**
|
||||
* Indicator of system status
|
||||
*/
|
||||
indicator: StatusIndicator;
|
||||
/**
|
||||
* Human-readable status description
|
||||
*/
|
||||
description: string }
|
||||
export type TableInfo = { name: string; sql: string }
|
||||
export type UnityPackageSummary = { id?: string | null; assetUrl?: string | null; assetVersion?: number | null; platform?: string | null; unityVersion?: string | null; createdAt?: string | null; performanceRating?: string | null; scanStatus?: string | null; variant?: string | null; unitySortNumber?: number | null; impostorizerVersion?: string | null }
|
||||
export type UpdateStatusRequest = { status: UserStatus; statusDescription: string }
|
||||
export type User = { id: string; username?: string; displayName: string; acceptedPrivacyVersion?: number | null; acceptedTosVersion?: number | null; accountDeletionDate?: string | null; state?: string; status?: UserStatus; statusDescription?: string; statusFirstTime?: boolean | null; statusHistory?: string[]; bio?: string; bioLinks?: string[]; ageVerificationStatus?: AgeVerificationStatus; ageVerified?: boolean | null; isAdult?: boolean | null; dateJoined?: string | null; lastLogin?: string | null; lastActivity?: string | null; lastPlatform?: string | null; lastMobile?: string | null; platform?: string; platformHistory?: string[]; location?: string | null; travelingToWorld?: string | null; travelingToLocation?: string | null; travelingToInstance?: string | null; homeLocation?: string | null; instanceId?: string | null; worldId?: string | null; allowAvatarCopying?: boolean | null; twoFactorAuthEnabled?: boolean | null; twoFactorAuthEnabledDate?: string | null; currentAvatar?: string | null; fallbackAvatar?: string | null; currentAvatarTags?: string[]; profilePicOverride?: string | null; profilePicOverrideThumbnail?: string | null; userIcon?: string | null; currentAvatarImageUrl?: string | null; currentAvatarThumbnailImageUrl?: string | null; bannerId?: string | null; bannerUrl?: string | null; pronouns?: string | null; languages?: string[] | null; pronounsHistory?: string[]; friends?: string[]; friendGroupNames?: string[]; friendKey?: string | null; friendRequestStatus?: FriendRequestStatus; pastDisplayNames?: PastDisplayName[] | null; badges?: Badge[] | null; tags?: string[]; isFriend?: boolean | null; note?: string | null; developerType?: DeveloperType; isBoopingEnabled?: boolean | null; receiveMobileInvitations?: boolean | null; hideContentFilterSettings?: boolean | null; hasBirthday?: boolean | null; hasEmail?: boolean | null; hasPendingEmail?: boolean | null; hasLoggedInFromClient?: boolean | null; unsubscribe?: boolean | null; updatedAt?: string | null; emailVerified?: boolean | null; obfuscatedEmail?: string | null; userLanguage?: string | null; userLanguageCode?: string | null; discordId?: string | null; discordDetails?: DiscordDetails | null; googleId?: string | null; googleDetails?: GoogleDetails | null; steamId?: string | null; steamDetails?: SteamDetails | null; oculusId?: string | null; picoId?: string | null; viveId?: string | null }
|
||||
/**
|
||||
* State of the user
|
||||
*/
|
||||
export type UserState =
|
||||
/**
|
||||
* User is offline
|
||||
*/
|
||||
"offline" |
|
||||
/**
|
||||
* User is active
|
||||
*/
|
||||
"active" |
|
||||
/**
|
||||
* User is online
|
||||
*/
|
||||
"online"
|
||||
/**
|
||||
* User's current status
|
||||
*/
|
||||
export type UserStatus =
|
||||
/**
|
||||
* User is online and active
|
||||
*/
|
||||
"active" |
|
||||
/**
|
||||
* User is online and auto accepting invitations to join
|
||||
*/
|
||||
"join me" |
|
||||
/**
|
||||
* User is online but is hiding their location and requires invitation to join
|
||||
*/
|
||||
"ask me" |
|
||||
/**
|
||||
* User is busy
|
||||
*/
|
||||
"busy" |
|
||||
/**
|
||||
* User is offline
|
||||
*/
|
||||
"offline"
|
||||
/**
|
||||
* Main error type for VRChat API operations
|
||||
*/
|
||||
export type VRCError =
|
||||
/**
|
||||
* Network-related errors
|
||||
*/
|
||||
{ type: "Network"; data: string } |
|
||||
/**
|
||||
* HTTP errors with status code
|
||||
*/
|
||||
{ type: "Http"; data: { status: number; message: string } } |
|
||||
/**
|
||||
* Authentication errors
|
||||
*/
|
||||
{ type: "Authentication"; data: string } |
|
||||
/**
|
||||
* Rate limiting error
|
||||
*/
|
||||
{ type: "RateLimit"; data: string } |
|
||||
/**
|
||||
* JSON parsing errors
|
||||
*/
|
||||
{ type: "Parse"; data: string } |
|
||||
/**
|
||||
* Invalid input or request
|
||||
*/
|
||||
{ type: "InvalidInput"; data: string } |
|
||||
/**
|
||||
* Unknown or unexpected errors
|
||||
*/
|
||||
{ type: "Unknown"; data: string }
|
||||
/**
|
||||
* Response from VRChat status API
|
||||
*/
|
||||
export type VRChatStatusResponse = { page: StatusPage; status: SystemStatus }
|
||||
|
||||
/** tauri-specta globals **/
|
||||
|
||||
import {
|
||||
invoke as TAURI_INVOKE,
|
||||
Channel as TAURI_CHANNEL,
|
||||
} from "@tauri-apps/api/core";
|
||||
import * as TAURI_API_EVENT from "@tauri-apps/api/event";
|
||||
import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
|
||||
|
||||
type __EventObj__<T> = {
|
||||
listen: (
|
||||
cb: TAURI_API_EVENT.EventCallback<T>,
|
||||
) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
|
||||
once: (
|
||||
cb: TAURI_API_EVENT.EventCallback<T>,
|
||||
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
|
||||
emit: null extends T
|
||||
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
|
||||
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
|
||||
};
|
||||
|
||||
export type Result<T, E> =
|
||||
| { status: "ok"; data: T }
|
||||
| { status: "error"; error: E };
|
||||
|
||||
function __makeEvents__<T extends Record<string, any>>(
|
||||
mappings: Record<keyof T, string>,
|
||||
) {
|
||||
return new Proxy(
|
||||
{} as unknown as {
|
||||
[K in keyof T]: __EventObj__<T[K]> & {
|
||||
(handle: __WebviewWindow__): __EventObj__<T[K]>;
|
||||
};
|
||||
},
|
||||
{
|
||||
get: (_, event) => {
|
||||
const name = mappings[event as keyof T];
|
||||
|
||||
return new Proxy((() => {}) as any, {
|
||||
apply: (_, __, [window]: [__WebviewWindow__]) => ({
|
||||
listen: (arg: any) => window.listen(name, arg),
|
||||
once: (arg: any) => window.once(name, arg),
|
||||
emit: (arg: any) => window.emit(name, arg),
|
||||
}),
|
||||
get: (_, command: keyof __EventObj__<any>) => {
|
||||
switch (command) {
|
||||
case "listen":
|
||||
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
|
||||
case "once":
|
||||
return (arg: any) => TAURI_API_EVENT.once(name, arg);
|
||||
case "emit":
|
||||
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
197
src/types/errors.ts
Normal file
197
src/types/errors.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { VRCError } from './bindings';
|
||||
|
||||
/**
|
||||
* Custom VRCError Wrapper class
|
||||
*/
|
||||
export class VRChatError extends Error {
|
||||
public readonly vrcError: VRCError;
|
||||
|
||||
constructor(vrcError: VRCError) {
|
||||
super(getErrorMessage(vrcError));
|
||||
this.name = 'VRChatError';
|
||||
this.vrcError = vrcError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the error type
|
||||
*/
|
||||
get type(): VRCError['type'] {
|
||||
return this.vrcError.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a network error
|
||||
*/
|
||||
isNetwork(): boolean {
|
||||
return this.vrcError.type === 'Network';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an HTTP error
|
||||
*/
|
||||
isHttp(): boolean {
|
||||
return this.vrcError.type === 'Http';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an authentication error
|
||||
*/
|
||||
isAuthentication(): boolean {
|
||||
return this.vrcError.type === 'Authentication';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a rate limit error
|
||||
*/
|
||||
isRateLimit(): boolean {
|
||||
return this.vrcError.type === 'RateLimit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a parse error
|
||||
*/
|
||||
isParse(): boolean {
|
||||
return this.vrcError.type === 'Parse';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is an invalid input error
|
||||
*/
|
||||
isInvalidInput(): boolean {
|
||||
return this.vrcError.type === 'InvalidInput';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTTP status code if this is an HTTP error
|
||||
*/
|
||||
getStatusCode(): number | null {
|
||||
if (this.vrcError.type === 'Http') {
|
||||
return this.vrcError.data.status;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message
|
||||
*/
|
||||
getUserMessage(): string {
|
||||
return getUserFriendlyMessage(this.vrcError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from VRCError
|
||||
*/
|
||||
export function getErrorMessage(error: VRCError): string {
|
||||
switch (error.type) {
|
||||
case 'Network':
|
||||
return `Network error: ${error.data}`;
|
||||
case 'Http':
|
||||
return `HTTP ${error.data.status}: ${error.data.message}`;
|
||||
case 'Authentication':
|
||||
return `Authentication error: ${error.data}`;
|
||||
case 'RateLimit':
|
||||
return `Rate limit: ${error.data}`;
|
||||
case 'Parse':
|
||||
return `Parse error: ${error.data}`;
|
||||
case 'InvalidInput':
|
||||
return `Invalid input: ${error.data}`;
|
||||
case 'Unknown':
|
||||
return `Unknown error: ${error.data}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message
|
||||
*/
|
||||
// TODO: Localize these messages later
|
||||
export function getUserFriendlyMessage(error: VRCError): string {
|
||||
switch (error.type) {
|
||||
case 'Network':
|
||||
return 'Network connection error. Please check your internet connection.';
|
||||
|
||||
case 'Http':
|
||||
const { status, message } = error.data;
|
||||
if (status === 401) {
|
||||
return 'Invalid credentials. Please check your email and password.';
|
||||
} else if (status === 403) {
|
||||
return 'Access forbidden. Your account may be restricted.';
|
||||
} else if (status === 404) {
|
||||
return 'Resource not found.';
|
||||
} else if (status === 429) {
|
||||
return 'Too many requests. Please wait a few minutes before trying again.';
|
||||
} else if (status >= 500) {
|
||||
return 'VRChat servers are experiencing issues. Please try again later.';
|
||||
}
|
||||
return message || `Server error (${status})`;
|
||||
|
||||
case 'Authentication':
|
||||
if (error.data.toLowerCase().includes('credentials')) {
|
||||
return 'Invalid email or password.';
|
||||
} else if (error.data.toLowerCase().includes('2fa') || error.data.toLowerCase().includes('two factor')) {
|
||||
return 'Two-factor authentication required.';
|
||||
}
|
||||
return 'Authentication failed. Please try again.';
|
||||
|
||||
case 'RateLimit':
|
||||
return 'Too many attempts. Please wait a few minutes before trying again.';
|
||||
|
||||
case 'Parse':
|
||||
return 'Failed to process server response. Please try again.';
|
||||
|
||||
case 'InvalidInput':
|
||||
return error.data || 'Invalid input. Please check your information.';
|
||||
|
||||
case 'Unknown':
|
||||
return 'An unexpected error occurred. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Tauri error response into a VRChatError
|
||||
*/
|
||||
export function parseVRCError(error: unknown): VRChatError {
|
||||
// If it's already a VRChatError, return it
|
||||
if (error instanceof VRChatError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// If it's a string, try to parse it as JSON
|
||||
if (typeof error === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(error) as VRCError;
|
||||
return new VRChatError(parsed);
|
||||
} catch {
|
||||
// If parsing fails, treat it as an unknown error
|
||||
return new VRChatError({
|
||||
type: 'Unknown',
|
||||
data: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If it's an object with type and data properties
|
||||
// then it's likely a VRCError
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'type' in error &&
|
||||
'data' in error
|
||||
) {
|
||||
return new VRChatError(error as VRCError);
|
||||
}
|
||||
|
||||
// If it's a regular Error
|
||||
if (error instanceof Error) {
|
||||
return new VRChatError({
|
||||
type: 'Unknown',
|
||||
data: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback for any other type
|
||||
return new VRChatError({
|
||||
type: 'Unknown',
|
||||
data: String(error),
|
||||
});
|
||||
}
|
||||
69
src/types/store-studio.ts
Normal file
69
src/types/store-studio.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface StoreDebugInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type: "resource" | "simple";
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for store entries in Store Studio
|
||||
*/
|
||||
export interface BaseStoreEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store entry with real-time data
|
||||
*/
|
||||
export interface StoreEntry<T = unknown> extends BaseStoreEntry {
|
||||
getData: () => T;
|
||||
subscribe: (listener: (value: T) => void) => () => void;
|
||||
actions?: StoreAction[];
|
||||
debugInfo?: () => StoreDebugData;
|
||||
canEdit?: boolean;
|
||||
setData?: (value: T) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action that can be performed on a store
|
||||
*/
|
||||
export interface StoreAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
variant?: "default" | "outline" | "destructive" | "secondary";
|
||||
onClick: (scopeId?: string | null) => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug data for a store
|
||||
*/
|
||||
export interface StoreDebugData {
|
||||
updatedAt?: number | null;
|
||||
ageMs?: number | null;
|
||||
stale?: boolean;
|
||||
inflight?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
scopes?: StoreScopeDebugData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug data for a specific scope within a store
|
||||
*/
|
||||
export interface StoreScopeDebugData {
|
||||
scopeId: string | null;
|
||||
label: string;
|
||||
cache: unknown;
|
||||
updatedAt: number;
|
||||
ageMs: number | null;
|
||||
stale: boolean;
|
||||
inflight: boolean;
|
||||
isActiveScope: boolean;
|
||||
actions?: StoreAction[];
|
||||
}
|
||||
183
src/utils/logger.ts
Normal file
183
src/utils/logger.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
export enum LogLevel {
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
source: 'frontend' | 'backend';
|
||||
module: string;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
type LogListener = (entry: LogEntry) => void;
|
||||
|
||||
class Logger {
|
||||
private listeners = new Set<LogListener>();
|
||||
private logs: LogEntry[] = [];
|
||||
private maxLogs = 1000;
|
||||
private originalConsole = {
|
||||
log: console.log,
|
||||
info: console.info,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
debug: console.debug,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.interceptConsole();
|
||||
}
|
||||
|
||||
private interceptConsole() {
|
||||
const createInterceptor = (level: LogLevel, originalMethod: (...args: unknown[]) => void) => {
|
||||
return (...args: unknown[]) => {
|
||||
// Call original console method
|
||||
originalMethod.apply(console, args);
|
||||
|
||||
// Extract module from stack trace if possible
|
||||
const module = this.extractModuleFromStack();
|
||||
|
||||
// Create log entry
|
||||
const message = args.map(arg =>
|
||||
typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
|
||||
).join(' ');
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
source: 'frontend',
|
||||
module: module || 'unknown',
|
||||
message,
|
||||
data: args.length === 1 ? args[0] : args,
|
||||
};
|
||||
|
||||
this.addLog(entry);
|
||||
};
|
||||
};
|
||||
|
||||
console.log = createInterceptor(LogLevel.INFO, this.originalConsole.log);
|
||||
console.info = createInterceptor(LogLevel.INFO, this.originalConsole.info);
|
||||
console.warn = createInterceptor(LogLevel.WARN, this.originalConsole.warn);
|
||||
console.error = createInterceptor(LogLevel.ERROR, this.originalConsole.error);
|
||||
console.debug = createInterceptor(LogLevel.DEBUG, this.originalConsole.debug);
|
||||
}
|
||||
|
||||
public getOriginalConsole() {
|
||||
return this.originalConsole;
|
||||
}
|
||||
|
||||
private extractModuleFromStack(): string | null {
|
||||
try {
|
||||
const stack = new Error().stack;
|
||||
if (!stack) return null;
|
||||
|
||||
// Parse stack trace to find the calling module
|
||||
const lines = stack.split('\n');
|
||||
// Skip first 3 lines (Error, interceptor, and this method)
|
||||
for (let i = 3; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Extract file path from stack line
|
||||
const match = line.match(/\((.*?):\d+:\d+\)|at (.*?):\d+:\d+/);
|
||||
if (match) {
|
||||
const path = match[1] || match[2];
|
||||
if (path) {
|
||||
// Extract filename or meaningful path segment
|
||||
const segments = path.split('/');
|
||||
const filename = segments[segments.length - 1];
|
||||
|
||||
// Remove query params and file extension
|
||||
const cleanName = filename.split('?')[0].replace(/\.(tsx?|jsx?)$/, '');
|
||||
|
||||
// Ignore framework and library files
|
||||
if (!cleanName.includes('node_modules') &&
|
||||
!cleanName.includes('logger') &&
|
||||
!cleanName.includes('vite')) {
|
||||
return cleanName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail if stack parsing doesn't work
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private addLog(entry: LogEntry) {
|
||||
this.logs.push(entry);
|
||||
|
||||
if (this.logs.length > this.maxLogs) {
|
||||
this.logs = this.logs.slice(-this.maxLogs);
|
||||
}
|
||||
|
||||
this.emit(entry);
|
||||
}
|
||||
|
||||
public log(module: string, level: LogLevel, message: string, data?: unknown) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
source: 'frontend',
|
||||
module,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
|
||||
this.addLog(entry);
|
||||
|
||||
if (level === LogLevel.DEBUG) {
|
||||
this.originalConsole.debug(`[${module}]`, message, data);
|
||||
}
|
||||
}
|
||||
|
||||
public debug(module: string, message: string, data?: unknown) {
|
||||
this.log(module, LogLevel.DEBUG, message, data);
|
||||
}
|
||||
|
||||
public info(module: string, message: string, data?: unknown) {
|
||||
this.log(module, LogLevel.INFO, message, data);
|
||||
}
|
||||
|
||||
public warn(module: string, message: string, data?: unknown) {
|
||||
this.log(module, LogLevel.WARN, message, data);
|
||||
}
|
||||
|
||||
public error(module: string, message: string, data?: unknown) {
|
||||
this.log(module, LogLevel.ERROR, message, data);
|
||||
}
|
||||
|
||||
public getLogs(): LogEntry[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
|
||||
public clearLogs() {
|
||||
this.logs = [];
|
||||
}
|
||||
|
||||
public subscribe(listener: LogListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private emit(entry: LogEntry) {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(entry);
|
||||
} catch (error) {
|
||||
this.originalConsole.error('Logger listener error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public exportLogs(): string {
|
||||
return JSON.stringify(this.logs, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user