From 42db5d994632af870206f5138b824ab78a4a4607 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Mon, 29 Jun 2026 01:03:58 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Add=20instance=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/src/components/AppShell.tsx | 3 + .../src/features/navigation/NavContext.tsx | 5 + .../src/features/profile/GroupsSection.tsx | 7 +- .../src/features/profile/LocationSection.tsx | 29 ++- .../src/features/profile/ProfileView.tsx | 75 ++++--- .../src/features/profile/WorldsSection.tsx | 28 ++- .../src/features/settings/SettingsView.tsx | 8 +- .../features/world/CreateInstanceModal.tsx | 8 +- .../src/features/world/InstanceView.tsx | 200 ++++++++++++++++++ .../src/lib/i18n/locales/en/profile.json | 64 ++++++ .../src/lib/i18n/locales/en/world.json | 44 ++-- .../src/lib/i18n/locales/ja/profile.json | 64 ++++++ .../src/lib/i18n/locales/ja/world.json | 44 ++-- .../src/lib/i18n/locales/th/profile.json | 64 ++++++ .../src/lib/i18n/locales/th/world.json | 44 ++-- src/renderer/src/lib/vrchat.ts | 32 ++- src/renderer/src/store/worlds.ts | 1 - 17 files changed, 617 insertions(+), 103 deletions(-) create mode 100644 src/renderer/src/features/world/InstanceView.tsx create mode 100644 src/renderer/src/lib/i18n/locales/en/profile.json create mode 100644 src/renderer/src/lib/i18n/locales/ja/profile.json create mode 100644 src/renderer/src/lib/i18n/locales/th/profile.json diff --git a/src/renderer/src/components/AppShell.tsx b/src/renderer/src/components/AppShell.tsx index af2ef6f..9b8ec2e 100644 --- a/src/renderer/src/components/AppShell.tsx +++ b/src/renderer/src/components/AppShell.tsx @@ -15,6 +15,7 @@ import { import type { LucideIcon } from "lucide-react"; import { ProfileView } from "../features/profile/ProfileView"; import { WorldView } from "../features/world/WorldView"; +import { InstanceView } from "../features/world/InstanceView"; import { GroupView } from "../features/group/GroupView"; import { AccountSettingsView } from "../features/account/AccountSettingsView"; import { SearchView } from "../features/search/SearchView"; @@ -164,6 +165,8 @@ function Shell() {
{nav.current.kind === "world" ? ( + ) : nav.current.kind === "instance" ? ( + ) : nav.current.kind === "group" ? ( ) : nav.current.kind === "account" ? ( diff --git a/src/renderer/src/features/navigation/NavContext.tsx b/src/renderer/src/features/navigation/NavContext.tsx index 3bfda85..43cf559 100644 --- a/src/renderer/src/features/navigation/NavContext.tsx +++ b/src/renderer/src/features/navigation/NavContext.tsx @@ -3,6 +3,7 @@ import { createContext, useContext, useMemo, useState } from "react"; export type View = | { kind: "user"; id: "me" | string } | { kind: "world"; id: string } + | { kind: "instance"; worldId: string; instanceId: string; location: string } | { kind: "group"; id: string } | { kind: "account" } | { kind: "settings" } @@ -15,6 +16,7 @@ interface Nav { canBack: boolean; openUser: (id: "me" | string) => void; openWorld: (id: string) => void; + openInstance: (worldId: string, instanceId: string, location: string) => void; openGroup: (id: string) => void; openAccount: () => void; openSettings: () => void; @@ -44,6 +46,8 @@ export function NavProvider({ children }: { children: React.ReactNode }) { canBack: stack.length > 1, openUser: (id) => push({ kind: "user", id }), openWorld: (id) => push({ kind: "world", id }), + openInstance: (worldId, instanceId, location) => + push({ kind: "instance", worldId, instanceId, location }), openGroup: (id) => push({ kind: "group", id }), openAccount: () => root({ kind: "account" }), openSettings: () => root({ kind: "settings" }), @@ -62,6 +66,7 @@ function sameView(a: View, b: View): boolean { if (a.kind !== b.kind) return false; if (a.kind === "user" && b.kind === "user") return a.id === b.id; if (a.kind === "world" && b.kind === "world") return a.id === b.id; + if (a.kind === "instance" && b.kind === "instance") return a.location === b.location; if (a.kind === "group" && b.kind === "group") return a.id === b.id; return true; } diff --git a/src/renderer/src/features/profile/GroupsSection.tsx b/src/renderer/src/features/profile/GroupsSection.tsx index e8e9b53..e2673b6 100644 --- a/src/renderer/src/features/profile/GroupsSection.tsx +++ b/src/renderer/src/features/profile/GroupsSection.tsx @@ -1,6 +1,7 @@ import { Star, Users } from "lucide-react"; import type { Group } from "../../../../shared/types/group"; import { Avatar, CollapsibleCard, SkeletonGrid } from "../../components/ui"; +import { useT } from "../../lib/i18n"; import { useNav } from "../navigation/NavContext"; import { useUserGroups } from "./useUserGroups"; @@ -41,6 +42,7 @@ export function GroupsSection({ userId }: { userId: string }) { } function FeaturedGroup({ group }: { group: Group }) { + const t = useT(); const { openGroup } = useNav(); return ( + +
+
+ + {region ? : null} + +
+
+
+ + ); +} + +function JoinActions({ instance }: { instance: Instance }) { + const t = useT(); + const [joining, setJoining] = useState(false); + const [joinError, setJoinError] = useState(null); + const [inviteSent, setInviteSent] = useState(false); + const [inviteError, setInviteError] = useState(null); + + const join = async () => { + setJoining(true); + setJoinError(null); + try { + await api.game.join(instance.location); + } catch (err) { + setJoinError(errorMessage(err, "Failed to launch VRChat")); + } finally { + setJoining(false); + } + }; + + const inviteMe = async () => { + setInviteError(null); + try { + await api.instance.inviteSelf(instance.worldId, instance.instanceId); + setInviteSent(true); + } catch (err) { + setInviteError(errorMessage(err, "Failed to send invite")); + } + }; + + return ( +
+
+ + +
+ {joinError ? {joinError} : null} + {inviteError ? {inviteError} : null} +
+ ); +} + +function InstanceSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/src/renderer/src/lib/i18n/locales/en/profile.json b/src/renderer/src/lib/i18n/locales/en/profile.json new file mode 100644 index 0000000..b56226b --- /dev/null +++ b/src/renderer/src/lib/i18n/locales/en/profile.json @@ -0,0 +1,64 @@ +{ + "badge": { + "you": "You", + "friend": "Friend", + "ageVerified": "18+ Verified" + }, + "status": { + "offlineWas": "Offline ({{status}})" + }, + "addFriendError": "Couldn't send friend request.", + "tabs": { + "overview": "Overview", + "worlds": "Worlds", + "favorites": "Favorite Worlds", + "groups": "Groups" + }, + "sections": { + "note": "Your note", + "about": "About", + "details": "Details", + "languages": "Languages", + "avatarTags": "Avatar tags", + "badges": "Badges", + "formerNames": "Former names" + }, + "facts": { + "joined": "Joined", + "lastLogin": "Last login", + "lastActivity": "Last activity", + "platform": "Platform", + "state": "State", + "userId": "User ID" + }, + "formerName": { + "until": "until {{date}}" + }, + "state": { + "online": "In VRChat", + "active": "On website / mobile", + "offline": "Offline" + }, + "worlds": { + "title": "Worlds", + "favorites": "Favorite worlds", + "tip": { + "favorites": "favorites", + "online": "players online now", + "visits": "visits", + "capacity": "capacity" + }, + "visitsCount": "{{formattedCount}} visits" + }, + "groups": { + "title": "Groups", + "featured": "Featured group" + }, + "location": { + "title": "Currently in", + "by": "by {{author}}", + "instance": "Instance", + "inInstance": "{{n}} in instance", + "inWorld": "{{n}} in world" + } +} diff --git a/src/renderer/src/lib/i18n/locales/en/world.json b/src/renderer/src/lib/i18n/locales/en/world.json index eba085a..6bae587 100644 --- a/src/renderer/src/lib/i18n/locales/en/world.json +++ b/src/renderer/src/lib/i18n/locales/en/world.json @@ -1,5 +1,21 @@ { "createInstance": "Create Instance", + "regions": { + "us": "US West", + "use": "US East", + "eu": "Europe", + "jp": "Japan" + }, + "accessTypes": { + "public": "Public", + "hidden": "Friends+", + "friends": "Friends", + "private": "Invite", + "inviteRequest": "Invite+", + "group": "Group", + "groupPlus": "Group+", + "groupPublic": "Group Public" + }, "create": { "title": "Create Instance", "type": "Access", @@ -8,19 +24,6 @@ "creating": "Creating…", "detecting": "Detecting best region…", "autoDetected": "Auto: {{region}}", - "types": { - "public": "Public", - "friends+": "Friends+", - "friends": "Friends", - "invite": "Invite", - "invite+": "Invite+" - }, - "regions": { - "us": "US West", - "use": "US East", - "eu": "Europe", - "jp": "Japan" - }, "ready": "Instance created.", "launch": "Launch VRChat", "launching": "Launching…", @@ -32,5 +35,20 @@ "unlockedHint": "Bypasses the instance rules. Anyone with it can join. Not recommended.", "copy": "Copy", "copied": "Copied" + }, + "instance": { + "title": "Instance", + "unavailable": "This instance is no longer available.", + "players": "Players", + "capacity": "Capacity", + "region": "Region", + "access": "Access", + "queue": "In queue", + "join": "Join in VRChat", + "joining": "Launching…", + "inviteMe": "Invite me", + "inviteSent": "Invite sent. Check VRChat.", + "worldDetails": "View world details", + "instanceId": "Instance ID" } } diff --git a/src/renderer/src/lib/i18n/locales/ja/profile.json b/src/renderer/src/lib/i18n/locales/ja/profile.json new file mode 100644 index 0000000..f395547 --- /dev/null +++ b/src/renderer/src/lib/i18n/locales/ja/profile.json @@ -0,0 +1,64 @@ +{ + "badge": { + "you": "あなた", + "friend": "フレンド", + "ageVerified": "18歳以上確認済み" + }, + "status": { + "offlineWas": "オフライン({{status}})" + }, + "addFriendError": "フレンド申請を送信できませんでした。", + "tabs": { + "overview": "概要", + "worlds": "ワールド", + "favorites": "お気に入りのワールド", + "groups": "グループ" + }, + "sections": { + "note": "あなたのメモ", + "about": "自己紹介", + "details": "詳細", + "languages": "言語", + "avatarTags": "アバタータグ", + "badges": "バッジ", + "formerNames": "以前の名前" + }, + "facts": { + "joined": "登録日", + "lastLogin": "最終ログイン", + "lastActivity": "最終アクティビティ", + "platform": "プラットフォーム", + "state": "状態", + "userId": "ユーザーID" + }, + "formerName": { + "until": "{{date}}まで" + }, + "state": { + "online": "VRChat内", + "active": "ウェブ/モバイル", + "offline": "オフライン" + }, + "worlds": { + "title": "ワールド", + "favorites": "お気に入りのワールド", + "tip": { + "favorites": "お気に入り数", + "online": "現在のオンラインプレイヤー", + "visits": "訪問数", + "capacity": "定員" + }, + "visitsCount": "{{formattedCount}} 回訪問" + }, + "groups": { + "title": "グループ", + "featured": "代表グループ" + }, + "location": { + "title": "現在地", + "by": "作者: {{author}}", + "instance": "インスタンス", + "inInstance": "インスタンスに{{n}}人", + "inWorld": "ワールドに{{n}}人" + } +} diff --git a/src/renderer/src/lib/i18n/locales/ja/world.json b/src/renderer/src/lib/i18n/locales/ja/world.json index b168c8e..250fc26 100644 --- a/src/renderer/src/lib/i18n/locales/ja/world.json +++ b/src/renderer/src/lib/i18n/locales/ja/world.json @@ -1,5 +1,21 @@ { "createInstance": "インスタンスを作成", + "regions": { + "us": "米国西部", + "use": "米国東部", + "eu": "ヨーロッパ", + "jp": "日本" + }, + "accessTypes": { + "public": "パブリック", + "hidden": "フレンド+", + "friends": "フレンド", + "private": "インバイト", + "inviteRequest": "インバイト+", + "group": "グループ", + "groupPlus": "グループ+", + "groupPublic": "グループパブリック" + }, "create": { "title": "インスタンスを作成", "type": "アクセス", @@ -8,19 +24,6 @@ "creating": "作成中…", "detecting": "最適なリージョンを検出中…", "autoDetected": "自動: {{region}}", - "types": { - "public": "パブリック", - "friends+": "フレンド+", - "friends": "フレンド", - "invite": "インバイト", - "invite+": "インバイト+" - }, - "regions": { - "us": "米国西部", - "use": "米国東部", - "eu": "ヨーロッパ", - "jp": "日本" - }, "ready": "インスタンスを作成しました。", "launch": "VRChatを起動", "launching": "起動中…", @@ -32,5 +35,20 @@ "unlockedHint": "インスタンスのルールを無視します。リンクを持つ誰でも参加できます。推奨しません。", "copy": "コピー", "copied": "コピーしました" + }, + "instance": { + "title": "インスタンス", + "unavailable": "このインスタンスはもう利用できません。", + "players": "プレイヤー", + "capacity": "定員", + "region": "リージョン", + "access": "アクセス", + "queue": "待機列", + "join": "VRChatで参加", + "joining": "起動中…", + "inviteMe": "自分を招待", + "inviteSent": "招待を送信しました。VRChatを確認してください", + "worldDetails": "ワールドの詳細を見る", + "instanceId": "インスタンスID" } } diff --git a/src/renderer/src/lib/i18n/locales/th/profile.json b/src/renderer/src/lib/i18n/locales/th/profile.json new file mode 100644 index 0000000..ddd1dd0 --- /dev/null +++ b/src/renderer/src/lib/i18n/locales/th/profile.json @@ -0,0 +1,64 @@ +{ + "badge": { + "you": "คุณ", + "friend": "เพื่อน", + "ageVerified": "ยืนยันอายุ 18+" + }, + "status": { + "offlineWas": "ออฟไลน์ ({{status}})" + }, + "addFriendError": "ส่งคำขอเป็นเพื่อนไม่สำเร็จ", + "tabs": { + "overview": "ภาพรวม", + "worlds": "เวิลด์", + "favorites": "เวิลด์ที่ชื่นชอบ", + "groups": "กลุ่ม" + }, + "sections": { + "note": "บันทึกของคุณ", + "about": "เกี่ยวกับ", + "details": "รายละเอียด", + "languages": "ภาษา", + "avatarTags": "แท็กอวตาร", + "badges": "เหรียญตรา", + "formerNames": "ชื่อเดิม" + }, + "facts": { + "joined": "เข้าร่วมเมื่อ", + "lastLogin": "เข้าสู่ระบบล่าสุด", + "lastActivity": "กิจกรรมล่าสุด", + "platform": "แพลตฟอร์ม", + "state": "สถานะ", + "userId": "รหัสผู้ใช้" + }, + "formerName": { + "until": "จนถึง {{date}}" + }, + "state": { + "online": "อยู่ใน VRChat", + "active": "บนเว็บ / มือถือ", + "offline": "ออฟไลน์" + }, + "worlds": { + "title": "เวิลด์", + "favorites": "เวิลด์ที่ชื่นชอบ", + "tip": { + "favorites": "รายการโปรด", + "online": "ผู้เล่นออนไลน์ตอนนี้", + "visits": "การเข้าชม", + "capacity": "ความจุ" + }, + "visitsCount": "เข้าชม {{formattedCount}} ครั้ง" + }, + "groups": { + "title": "กลุ่ม", + "featured": "กลุ่มที่นำเสนอ" + }, + "location": { + "title": "อยู่ที่", + "by": "โดย {{author}}", + "instance": "อินสแตนซ์", + "inInstance": "{{n}} คนในอินสแตนซ์", + "inWorld": "{{n}} คนในเวิลด์" + } +} diff --git a/src/renderer/src/lib/i18n/locales/th/world.json b/src/renderer/src/lib/i18n/locales/th/world.json index 9d54a1d..39b00cf 100644 --- a/src/renderer/src/lib/i18n/locales/th/world.json +++ b/src/renderer/src/lib/i18n/locales/th/world.json @@ -1,5 +1,21 @@ { "createInstance": "สร้างอินสแตนซ์", + "regions": { + "us": "สหรัฐฯ ฝั่งตะวันตก", + "use": "สหรัฐฯ ฝั่งตะวันออก", + "eu": "ยุโรป", + "jp": "ญี่ปุ่น" + }, + "accessTypes": { + "public": "สาธารณะ", + "hidden": "เพื่อน+", + "friends": "เพื่อน", + "private": "เชิญ", + "inviteRequest": "เชิญ+", + "group": "กลุ่ม", + "groupPlus": "กลุ่ม+", + "groupPublic": "กลุ่มสาธารณะ" + }, "create": { "title": "สร้างอินสแตนซ์", "type": "การเข้าถึง", @@ -8,19 +24,6 @@ "creating": "กำลังสร้าง…", "detecting": "กำลังตรวจหาภูมิภาคที่ดีที่สุด…", "autoDetected": "อัตโนมัติ: {{region}}", - "types": { - "public": "สาธารณะ", - "friends+": "เพื่อน+", - "friends": "เพื่อน", - "invite": "เชิญ", - "invite+": "เชิญ+" - }, - "regions": { - "us": "สหรัฐฯ ฝั่งตะวันตก", - "use": "สหรัฐฯ ฝั่งตะวันออก", - "eu": "ยุโรป", - "jp": "ญี่ปุ่น" - }, "ready": "สร้างอินสแตนซ์แล้ว", "launch": "เปิด VRChat", "launching": "กำลังเปิด…", @@ -32,5 +35,20 @@ "unlockedHint": "ข้ามกฎของอินสแตนซ์ ใครก็ตามที่มีลิงก์สามารถเข้าร่วมได้ ไม่แนะนำ", "copy": "คัดลอก", "copied": "คัดลอกแล้ว" + }, + "instance": { + "title": "อินสแตนซ์", + "unavailable": "อินสแตนซ์นี้ไม่สามารถใช้งานได้แล้ว", + "players": "ผู้เล่น", + "capacity": "ความจุ", + "region": "ภูมิภาค", + "access": "การเข้าถึง", + "queue": "อยู่ในคิว", + "join": "เข้าร่วมใน VRChat", + "joining": "กำลังเปิด…", + "inviteMe": "เชิญตัวเอง", + "inviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat", + "worldDetails": "ดูรายละเอียดเวิลด์", + "instanceId": "รหัสอินสแตนซ์" } } diff --git a/src/renderer/src/lib/vrchat.ts b/src/renderer/src/lib/vrchat.ts index 8039f86..3c5f963 100644 --- a/src/renderer/src/lib/vrchat.ts +++ b/src/renderer/src/lib/vrchat.ts @@ -57,13 +57,6 @@ export const developerLabels: Record = { moderator: "VRChat Moderator", }; -export const regionLabels: Record = { - us: "US West", - use: "US East", - eu: "Europe", - jp: "Japan", -}; - export const regionFlags: Record = { us: "🇺🇸", use: "🇺🇸", @@ -75,6 +68,31 @@ export function regionFlag(region?: string): string | undefined { return region ? regionFlags[region.toLowerCase()] : undefined; } +type TFunc = (key: string, opts?: Record) => string; + +export function regionLabel(t: TFunc, region?: string): string | null { + if (!region) return null; + const key = `world:regions.${region.toLowerCase()}`; + const label = t(key); + return label === key ? region.toUpperCase() : label; +} + +const createTypeToAccess: Record = { + public: "public", + "friends+": "hidden", + friends: "friends", + invite: "private", + "invite+": "inviteRequest", +}; + +export function accessLabel(t: TFunc, type?: string): string { + if (!type) return t("world:instance.title"); + const sdkType = createTypeToAccess[type] ?? type; + const key = `world:accessTypes.${sdkType}`; + const label = t(key); + return label === key ? type : label; +} + const languageNames: Record = { eng: "English", kor: "Korean", diff --git a/src/renderer/src/store/worlds.ts b/src/renderer/src/store/worlds.ts index 70addc8..d072223 100644 --- a/src/renderer/src/store/worlds.ts +++ b/src/renderer/src/store/worlds.ts @@ -21,7 +21,6 @@ export const useWorlds = create((set) => ({ upsert: (w) => set((st) => ({ worlds: { ...st.worlds, [w.id]: w } })), })); - let pending: World[] = []; let flushScheduled = false;