feat: Initial implementation

This commit is contained in:
2025-10-24 11:35:35 +07:00
commit cdeaab4e0a
143 changed files with 25287 additions and 0 deletions

64
src/App.css Normal file
View 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
View 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;

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
}

View 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>
)
}

View File

@@ -0,0 +1,12 @@
import { Toaster } from 'sonner';
export function ToasterComponent() {
return (
<Toaster
theme="dark"
position="bottom-right"
expand
richColors
/>
);
}

View 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 };

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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 };

View 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,
};

View 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 }

View 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
View 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,
}

View 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 }

View 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 };

View 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
View 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;
}

View 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
View 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
View 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);
},
};
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}
}

View 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
View 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
View 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);
}
}
}

View 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
View 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
View 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);
}
}
}

View 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);
};
}

View 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
View 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());

View 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());

View 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
View 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';

View 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;
}
}

View 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
View 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.

View 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()
);

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />