mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Merge PR #82: feat(scope) lock UI to a bot via dedicated link
This commit is contained in:
commit
d1544bab42
10 files changed
+401
-8
No files matched your search
@@ -0,0 +1,156 @@
|
|||||||
|
# Dedicated-link Bot Scoping (+ refresh fix) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Opening a dedicated link locks the WebUI to that one bot (selector shows only it, switching disabled, with an explicit exit); the lock is carried in the URL (`?bot=<id>`) so it survives refresh — fixing item 4 too.
|
||||||
|
|
||||||
|
**Architecture:** A `scopedBotId` in the Pinia player store is the runtime lock; the URL query `?bot=<id>` is the durable source of truth. A router `beforeEach` syncs scope from the query and re-attaches `?bot` across in-app navigation while scoped. `BotRedirect` seeds it; Navbar renders the lock; graceful clear if the bot doesn't exist.
|
||||||
|
|
||||||
|
**Tech:** Vue 3 + Pinia + vue-router, TypeScript. (Frontend isn't unit-tested in this repo → verify via `vue-tsc` + manual; extract one pure helper to unit-test.)
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Store scope state + pure resolve helper (with test)
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/stores/player.ts`; create `web/src/stores/scope.ts` + `web/src/stores/scope.test.ts`.
|
||||||
|
|
||||||
|
READ `web/src/stores/player.ts` first: `activeBotId` state (~line 52), `setActiveBotId` action (~127-133), `fetchBots` (~196-198 default to bots[0]), `activeBot` getter (~73-75), and the localStorage pattern used by `theme` (~175-183) for reference (we are NOT using localStorage, but match code style).
|
||||||
|
|
||||||
|
- [ ] **Step 1 — pure helper + failing test.** Create `web/src/stores/scope.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Given the desired scoped id (from ?bot) and the known bot ids, decide the
|
||||||
|
* effective scope. Returns the id if it exists, else null (graceful clear:
|
||||||
|
* a stale/forbidden id never locks the UI). */
|
||||||
|
export function resolveScopedBot(
|
||||||
|
requestedId: string | null | undefined,
|
||||||
|
knownBotIds: readonly string[],
|
||||||
|
): string | null {
|
||||||
|
if (!requestedId) return null;
|
||||||
|
return knownBotIds.includes(requestedId) ? requestedId : null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`web/src/stores/scope.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolveScopedBot } from "./scope.js";
|
||||||
|
|
||||||
|
describe("resolveScopedBot", () => {
|
||||||
|
it("returns null when no id requested", () => {
|
||||||
|
expect(resolveScopedBot(null, ["a", "b"])).toBeNull();
|
||||||
|
expect(resolveScopedBot(undefined, ["a"])).toBeNull();
|
||||||
|
expect(resolveScopedBot("", ["a"])).toBeNull();
|
||||||
|
});
|
||||||
|
it("returns the id when it exists in the bot list", () => {
|
||||||
|
expect(resolveScopedBot("b", ["a", "b"])).toBe("b");
|
||||||
|
});
|
||||||
|
it("clears (null) when the requested id is not a known bot", () => {
|
||||||
|
expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 — run, expect fail:** `npx vitest run web/src/stores/scope.test.ts` → module missing.
|
||||||
|
(Note: the repo's vitest runs from root; this test lives under web/. If the root vitest config doesn't include web/src, run it via the web workspace: `cd web && npx vitest run src/stores/scope.test.ts`. Use whichever picks it up; confirm it FAILS first.)
|
||||||
|
|
||||||
|
- [ ] **Step 3 — implement the helper** (code above).
|
||||||
|
|
||||||
|
- [ ] **Step 4 — add scope state to `web/src/stores/player.ts`:**
|
||||||
|
- state: `scopedBotId: null as string | null`.
|
||||||
|
- getter: `isScoped: (state) => state.scopedBotId !== null`.
|
||||||
|
- actions:
|
||||||
|
- `setScope(id: string)` → `this.scopedBotId = id;` and also set `this.activeBotId = id` (scoped == active), then ensure that bot's queue is loaded like `setActiveBotId` does.
|
||||||
|
- `clearScope()` → `this.scopedBotId = null;`.
|
||||||
|
- `applyScopeFromQuery(requestedId: string | null)` → uses `resolveScopedBot(requestedId, this.bots.map(b => b.id))`; if result non-null → `setScope(result)`; if null and a scope was requested → `clearScope()`. (Called after bots are loaded.)
|
||||||
|
- Guard `setActiveBotId(id)`: at the top, `if (this.scopedBotId !== null && id !== this.scopedBotId) return;` so switching is blocked while scoped.
|
||||||
|
|
||||||
|
- [ ] **Step 5 — run helper test, expect pass:** `cd web && npx vitest run src/stores/scope.test.ts` → 3 pass. `cd web && npx vue-tsc --noEmit` → exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 6 — commit:** `git add web/src/stores/scope.ts web/src/stores/scope.test.ts web/src/stores/player.ts && git commit -m "feat(scope): player store scopedBotId + resolveScopedBot helper"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Router guard — sync scope from `?bot` + preserve across navigation
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/router/index.ts`.
|
||||||
|
|
||||||
|
READ the file: the existing `beforeEach` (~lines 36-60) handles needsSetup/auth. Add scope handling AFTER auth resolves (so we don't fight the login redirect). Import the player store (use it inside the guard via `usePlayerStore()` — Pinia is active by the time navigation runs).
|
||||||
|
|
||||||
|
- [ ] **Step 1 — implement.** In `beforeEach`, after the existing auth/needsSetup logic decides the navigation is allowed to proceed to `to` (i.e., not redirecting to /login or /first-run), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const qBot = typeof to.query.bot === "string" ? to.query.bot : null;
|
||||||
|
if (qBot) {
|
||||||
|
// entering/with a scope in the URL — store will validate against bots later
|
||||||
|
store.scopedBotId = qBot; // tentative; applyScopeFromQuery (after fetchBots) confirms/clears
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
if (store.scopedBotId) {
|
||||||
|
// scoped but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh
|
||||||
|
if (to.query.bot !== store.scopedBotId) {
|
||||||
|
return next({ ...to, query: { ...to.query, bot: store.scopedBotId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adapt to the file's existing `next()` style — it may use `next(...)`/return. Ensure this runs only for allowed navigations, not when redirecting to /login. The exit action in Task 4 calls `store.clearScope()` BEFORE navigating to `/`, so `store.scopedBotId` is null and the re-attach branch is skipped — that's how exit works.)
|
||||||
|
|
||||||
|
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Re-read the guard to ensure no redirect loop (when `to.query.bot === store.scopedBotId`, it does NOT redirect again).
|
||||||
|
|
||||||
|
- [ ] **Step 3 — commit:** `git add web/src/router/index.ts && git commit -m "feat(scope): router guard syncs + preserves ?bot across navigation"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: BotRedirect seeds the URL scope
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/views/BotRedirect.vue`.
|
||||||
|
|
||||||
|
READ it: onMounted reads `route.params.id`, ensures `store.fetchBots()`, finds the bot; if found `store.setActiveBotId(id)` + `router.replace('/')`; else shows not-found.
|
||||||
|
|
||||||
|
- [ ] **Step 1 — implement.** Change the found-branch to seed scope via the URL instead of bouncing to a bare `/`:
|
||||||
|
- keep the fetchBots + existence check,
|
||||||
|
- if found: `router.replace({ path: '/', query: { bot: botId } })` (the router guard + store will set the scope). Optionally also call `store.setScope(botId)` directly for immediacy.
|
||||||
|
- if not found: unchanged (show "机器人不存在或未加载").
|
||||||
|
|
||||||
|
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0.
|
||||||
|
- [ ] **Step 3 — commit:** `git add web/src/views/BotRedirect.vue && git commit -m "feat(scope): dedicated link seeds ?bot scope instead of bare redirect"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Navbar lock UI + apply-scope-on-load
|
||||||
|
|
||||||
|
**Files:** Modify `web/src/components/Navbar.vue`, `web/src/App.vue`.
|
||||||
|
|
||||||
|
READ both: Navbar has `controllableBots` (computed) + the dropdown selector + `selectBot`; App.vue onMounted calls `playerStore.fetchBots()` (+ loadTheme/connect).
|
||||||
|
|
||||||
|
- [ ] **Step 1 — Navbar lock.** When `store.isScoped`:
|
||||||
|
- render only the scoped bot (a `displayedBots` computed → if scoped, `controllableBots.filter(b => b.id === store.scopedBotId)`, else `controllableBots`),
|
||||||
|
- disable the dropdown open / switching (no chevron, or make the trigger non-interactive) so the user can't switch,
|
||||||
|
- hide other bots' "copy link" affordances (only the scoped bot remains anyway),
|
||||||
|
- show a small "专属模式" badge and an "退出" button → `store.clearScope(); router.push('/')` (clear BEFORE navigating so the guard doesn't re-attach `?bot`). Import `useRouter` if not present.
|
||||||
|
When not scoped: behavior unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 2 — apply scope on load (App.vue).** After `fetchBots()` resolves in onMounted, call `playerStore.applyScopeFromQuery(routeBot)` where `routeBot` is the current `?bot` query (via `useRoute().query.bot` as string|null). This confirms a refreshed `?bot` against the loaded bots and sets activeBotId (or gracefully clears if the bot is gone). (If Task 2's guard already set `scopedBotId` tentatively, this validates it against the now-loaded bot list.)
|
||||||
|
|
||||||
|
- [ ] **Step 3 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Read templates back for valid syntax; confirm read-only displays aren't broken and the non-scoped path is unchanged.
|
||||||
|
- [ ] **Step 4 — commit:** `git add web/src/components/Navbar.vue web/src/App.vue && git commit -m "feat(scope): lock Navbar selector to scoped bot + apply scope on load"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
|
||||||
|
- [ ] `npx tsc --noEmit` → exit 0 (backend unaffected)
|
||||||
|
- [ ] `cd web && npx vitest run src/stores/scope.test.ts` (or root vitest if it includes web) → pass
|
||||||
|
- [ ] `npm run build` → succeeds
|
||||||
|
- [ ] Manual: open `/bot/<id>` → URL becomes `/?bot=<id>`, selector shows only that bot, switching disabled; **refresh → still locked** (item 4 fixed); navigate to Search → URL keeps `?bot`; refresh on Search → still locked; click 退出 → back to all bots (`/`, no `?bot`); open `/` directly → full multi-bot control; open `/?bot=<nonexistent>` → gracefully shows all bots (no lock).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Backend per-bot authorization (PR #80) is the real security boundary; this is a UX lock.
|
||||||
|
- No localStorage — URL is the source of truth, so the lock is shareable and self-clearing.
|
||||||
|
- Item 4 is fixed as a consequence of carrying `?bot` in the URL across refresh/navigation.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Dedicated-link bot scoping (+ refresh fix) — design
|
||||||
|
|
||||||
|
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) items 2 and 4
|
||||||
|
**Date:** 2026-05-30
|
||||||
|
**Status:** Approved (brainstorm), pending implementation plan
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
- **Item 2:** A dedicated link (`/bot/:id`) is meant to give someone control of *one* bot, but today it just sets the active bot and bounces to `/`; the user can still switch to any other bot from the top-right selector.
|
||||||
|
- **Item 4 (bug):** After opening a dedicated link, refreshing the page loses the bot — the UI falls back to the first bot.
|
||||||
|
|
||||||
|
Root cause (verified): `BotRedirect.vue` does `router.replace('/')` (dropping the id), and `activeBotId` is in-memory-only Pinia state with no persistence, so a reload resets it to `bots[0]`.
|
||||||
|
|
||||||
|
## Decision (from brainstorm: Q2 = URL-carried scope)
|
||||||
|
|
||||||
|
Carry the scoped bot in the **URL query** (`?bot=<id>`). One mechanism fixes **both** items: the URL is durable across refresh (item 4) and shareable/self-clearing, and the frontend locks the selector to the scoped bot (item 2). No localStorage sticky-lock; plain `/` (no `?bot`) = full control. Backend per-bot authorization (PR #80) remains the real boundary — this is a UX lock.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Scope state (store)
|
||||||
|
Add to the player store:
|
||||||
|
- `scopedBotId: string | null` — the bot the UI is locked to.
|
||||||
|
- getter `isScoped` = `scopedBotId !== null`.
|
||||||
|
- action `setScope(id)` / `clearScope()`.
|
||||||
|
- `setActiveBotId(id)` becomes a no-op (or ignores) when `isScoped` and `id !== scopedBotId`, so stray switch attempts can't change bots.
|
||||||
|
|
||||||
|
### URL as the durable source of truth
|
||||||
|
- `BotRedirect.vue` (`/bot/:id`): instead of `router.replace('/')`, validate the bot exists, then `router.replace({ path: '/', query: { bot: id } })`. (Keeps the "clean" home URL but with `?bot=`.)
|
||||||
|
- **Router `beforeEach` guard** (the heart of it):
|
||||||
|
- If `to.query.bot` is present → `store.setScope(to.query.bot)` and continue.
|
||||||
|
- Else if `store.isScoped` (a scope is active and this navigation dropped the param) → redirect to the same route **with** `query.bot = store.scopedBotId` re-attached (so the lock survives in-app navigation to /search, /library, etc.).
|
||||||
|
- Else → no scope; continue.
|
||||||
|
This keeps `?bot=` on the URL for every route while scoped, so a refresh on *any* route re-establishes the lock → **fixes item 4**.
|
||||||
|
- On app load / after `fetchBots()`: apply `scopedBotId`/`?bot` to `activeBotId`; if the scoped bot doesn't exist or isn't in the user's allowed set, **clear the scope gracefully** (fall back to normal multi-bot view) rather than locking onto a dead id.
|
||||||
|
|
||||||
|
### Exit
|
||||||
|
- `clearScope()` sets `scopedBotId = null`; the exit affordance navigates to `/` *after* clearing, so the guard won't re-attach `?bot`. This is the only way to leave scoped mode (self-clearing, intentional).
|
||||||
|
|
||||||
|
### Navbar (the lock UI)
|
||||||
|
- When `isScoped`: the bot selector shows **only** the scoped bot, the dropdown/switching is disabled (no chevron / non-interactive), and other bots' "copy link" affordances are not shown.
|
||||||
|
- Show a small "专属模式" indicator with an "退出" control → `clearScope()` + navigate to `/`.
|
||||||
|
- When not scoped: unchanged (full selector over `controllableBots`).
|
||||||
|
|
||||||
|
### Active-bot coherence
|
||||||
|
Because every player action already routes through `activeBotId`, locking `activeBotId === scopedBotId` guarantees all controls affect only the scoped bot. The store's `activeBot` getter `bots[0]` fallback still degrades safely if the scoped id ever fails to match (combined with the graceful-clear above).
|
||||||
|
|
||||||
|
## Components / files
|
||||||
|
|
||||||
|
- `web/src/stores/player.ts` — `scopedBotId` state, `isScoped`, `setScope`/`clearScope`, guard in `setActiveBotId`, apply scope→active in `fetchBots`/init (graceful clear if missing).
|
||||||
|
- `web/src/router/index.ts` — `beforeEach` scope sync + `?bot` preservation.
|
||||||
|
- `web/src/views/BotRedirect.vue` — set scope + `replace({ path: '/', query: { bot: id } })`.
|
||||||
|
- `web/src/components/Navbar.vue` — locked selector + "专属模式/退出" affordance.
|
||||||
|
- `web/src/App.vue` — ensure scope is applied to `activeBotId` after `fetchBots` on load (if not already handled by the store/guard).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Vue UI isn't unit-tested in this repo, so verification is `vue-tsc` + manual run. The **store scope logic is testable** if a lightweight test harness exists for Pinia stores; otherwise assert the pure pieces:
|
||||||
|
- `setActiveBotId` ignores a switch to a non-scoped bot while scoped; allows the scoped bot.
|
||||||
|
- `clearScope` resets state.
|
||||||
|
- A small helper for "resolve scope from query + bots list → {scopedBotId, activeBotId} or cleared-if-missing" can be extracted and unit-tested.
|
||||||
|
Manual: open `/bot/<id>` → locked to that bot, selector shows only it; refresh → still locked (item 4 fixed); navigate to Search then refresh → still locked; click 退出 → back to all bots; open `/` directly → full control (no lock).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No localStorage persistence (URL is the source of truth). No backend change (per-bot auth already exists in #80). No change to how dedicated links are generated (still `<base>/bot/<id>`); only what happens when one is opened.
|
||||||
+10
-2
@@ -140,12 +140,20 @@ function cycleMobileMode() {
|
|||||||
playerStore.setMode(nextMode);
|
playerStore.setMode(nextMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
playerStore.loadTheme();
|
playerStore.loadTheme();
|
||||||
connect();
|
connect();
|
||||||
playerStore.fetchBots();
|
|
||||||
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
|
syncTimer = setInterval(() => playerStore.syncElapsed(), 3000);
|
||||||
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
mobileRaf = requestAnimationFrame(updateMobileProgress);
|
||||||
|
// Reconcile the dedicated-link scope only after the bot list is known: the
|
||||||
|
// router guard sets scopedBotId tentatively from ?bot, but applyScopeFromQuery
|
||||||
|
// validates it against the loaded bots (locks if it exists, clears if stale).
|
||||||
|
await playerStore.fetchBots();
|
||||||
|
// Read from the authoritative current route (not a possibly-stale reactive
|
||||||
|
// snapshot) so the scope reconciles against the ?bot present at refresh time.
|
||||||
|
const routeBot = router.currentRoute.value.query.bot;
|
||||||
|
const qBot = typeof routeBot === 'string' ? routeBot : null;
|
||||||
|
playerStore.applyScopeFromQuery(qBot);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -10,8 +10,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<!-- Bot selector (always shown when at least one bot exists) -->
|
<!-- Scoped (dedicated link): static label locked to the one bot, no switching -->
|
||||||
<div v-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
|
<div v-if="store.isScoped" class="bot-selector scoped" ref="selectorRef">
|
||||||
|
<div class="bot-selector-btn static">
|
||||||
|
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
||||||
|
<span class="bot-selector-name">{{ activeBot?.name ?? '专属机器人' }}</span>
|
||||||
|
<span v-if="activeBot?.playing && !activeBot?.paused" class="bot-state-mini playing">▶</span>
|
||||||
|
<span v-else-if="activeBot?.paused" class="bot-state-mini paused">⏸</span>
|
||||||
|
<span class="scope-badge">专属模式</span>
|
||||||
|
</div>
|
||||||
|
<button class="scope-exit-btn" @click="exitScope" title="退出专属模式">退出</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Normal: full selector with switching (shown when at least one bot exists) -->
|
||||||
|
<div v-else-if="store.bots.length > 0" class="bot-selector" ref="selectorRef">
|
||||||
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
||||||
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
||||||
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
|
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
|
||||||
@@ -22,7 +34,7 @@
|
|||||||
<div v-if="dropdownOpen" class="bot-dropdown">
|
<div v-if="dropdownOpen" class="bot-dropdown">
|
||||||
<div class="bot-dropdown-header">机器人</div>
|
<div class="bot-dropdown-header">机器人</div>
|
||||||
<div
|
<div
|
||||||
v-for="bot in store.bots"
|
v-for="bot in displayedBots"
|
||||||
:key="bot.id"
|
:key="bot.id"
|
||||||
class="bot-card"
|
class="bot-card"
|
||||||
:class="{ active: bot.id === store.activeBotId }"
|
:class="{ active: bot.id === store.activeBotId }"
|
||||||
@@ -138,6 +150,11 @@ async function onLogout() {
|
|||||||
navRouter.replace({ name: 'login' });
|
navRouter.replace({ name: 'login' });
|
||||||
}
|
}
|
||||||
const activeBot = computed(() => store.activeBot);
|
const activeBot = computed(() => store.activeBot);
|
||||||
|
// While scoped (dedicated link), the selector is locked to the single scoped
|
||||||
|
// bot; otherwise the full list is shown and switching is allowed.
|
||||||
|
const displayedBots = computed(() =>
|
||||||
|
store.isScoped ? store.bots.filter((b) => b.id === store.scopedBotId) : store.bots,
|
||||||
|
);
|
||||||
const dropdownOpen = ref(false);
|
const dropdownOpen = ref(false);
|
||||||
const selectorRef = ref<HTMLElement | null>(null);
|
const selectorRef = ref<HTMLElement | null>(null);
|
||||||
const togglingBots = ref<Record<string, boolean>>({});
|
const togglingBots = ref<Record<string, boolean>>({});
|
||||||
@@ -156,6 +173,14 @@ function selectBot(id: string) {
|
|||||||
dropdownOpen.value = false;
|
dropdownOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Leave dedicated-link mode. Clear scope BEFORE navigating so the router guard
|
||||||
|
// (which re-attaches ?bot from scopedBotId) sees a null scope and lets us out.
|
||||||
|
function exitScope() {
|
||||||
|
store.clearScope();
|
||||||
|
dropdownOpen.value = false;
|
||||||
|
navRouter.push('/');
|
||||||
|
}
|
||||||
|
|
||||||
function resolveBaseUrl(): string {
|
function resolveBaseUrl(): string {
|
||||||
const base = publicBaseUrl.value;
|
const base = publicBaseUrl.value;
|
||||||
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
|
if (base && /^https?:\/\//i.test(base)) return base.replace(/\/+$/, '');
|
||||||
@@ -370,6 +395,60 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scoped (dedicated-link) selector: locked, non-interactive label + exit */
|
||||||
|
.bot-selector.scoped {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-selector-btn.static {
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-primary-15);
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-exit-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.bot-state-mini {
|
.bot-state-mini {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
&.playing { color: var(--color-online); }
|
&.playing { color: var(--color-online); }
|
||||||
|
|||||||
+7
-1
@@ -11,4 +11,10 @@ installApiClient();
|
|||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.use(createPinia());
|
app.use(createPinia());
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.mount('#app');
|
// Wait for the initial navigation (and the beforeEach guard that reads ?bot)
|
||||||
|
// to fully resolve before mounting, so the reactive route query is populated
|
||||||
|
// when App.onMounted runs and the dedicated-bot scope locks the right bot.
|
||||||
|
// .catch keeps parity with the old unconditional mount: if the initial
|
||||||
|
// navigation errors (e.g. a transient network failure in the auth guard),
|
||||||
|
// still render the shell rather than leaving a blank page.
|
||||||
|
router.isReady().catch(() => {}).then(() => app.mount('#app'));
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import { useSession } from '../composables/useSession.js';
|
import { useSession } from '../composables/useSession.js';
|
||||||
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -56,6 +57,22 @@ router.beforeEach(async (to) => {
|
|||||||
if (!session.isAuthenticated.value) {
|
if (!session.isAuthenticated.value) {
|
||||||
return { name: 'login', query: { next: to.fullPath } };
|
return { name: 'login', query: { next: to.fullPath } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Navigation is allowed to proceed to `to` past here (auth/setup redirects above take precedence).
|
||||||
|
// Sync + preserve the dedicated-link scope carried by ?bot.
|
||||||
|
const store = usePlayerStore();
|
||||||
|
const qBot = typeof to.query.bot === 'string' && to.query.bot ? to.query.bot : null;
|
||||||
|
if (qBot) {
|
||||||
|
// URL carries a scope — set tentatively; App.vue's applyScopeFromQuery (after fetchBots) validates/clears it.
|
||||||
|
store.scopedBotId = qBot;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (store.scopedBotId) {
|
||||||
|
// scoped, but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh.
|
||||||
|
if (to.query.bot !== store.scopedBotId) {
|
||||||
|
return { path: to.path, query: { ...to.query, bot: store.scopedBotId }, hash: to.hash };
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { resolveScopedBot } from './scope.js';
|
||||||
|
|
||||||
export interface Song {
|
export interface Song {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,6 +51,9 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
bots: [] as BotStatus[],
|
bots: [] as BotStatus[],
|
||||||
activeBotId: null as string | null,
|
activeBotId: null as string | null,
|
||||||
|
/** When set, the UI is locked to a single bot (dedicated link, from ?bot).
|
||||||
|
* Source of truth is the URL — never persisted to localStorage. */
|
||||||
|
scopedBotId: null as string | null,
|
||||||
/** Per-bot queues keyed by botId */
|
/** Per-bot queues keyed by botId */
|
||||||
queues: {} as Record<string, Song[]>,
|
queues: {} as Record<string, Song[]>,
|
||||||
/** Per-bot timing state keyed by botId */
|
/** Per-bot timing state keyed by botId */
|
||||||
@@ -73,6 +77,10 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
activeBot(): BotStatus | null {
|
activeBot(): BotStatus | null {
|
||||||
return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? null;
|
return this.bots.find((b) => b.id === this.activeBotId) ?? this.bots[0] ?? null;
|
||||||
},
|
},
|
||||||
|
/** True when the UI is locked to a single bot via a dedicated link. */
|
||||||
|
isScoped(): boolean {
|
||||||
|
return this.scopedBotId !== null;
|
||||||
|
},
|
||||||
currentSong(): Song | null {
|
currentSong(): Song | null {
|
||||||
return this.activeBot?.currentSong ?? null;
|
return this.activeBot?.currentSong ?? null;
|
||||||
},
|
},
|
||||||
@@ -125,6 +133,8 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
setActiveBotId(id: string) {
|
setActiveBotId(id: string) {
|
||||||
|
// While scoped to a dedicated link, switching bots is blocked.
|
||||||
|
if (this.scopedBotId !== null && id !== this.scopedBotId) return;
|
||||||
this.activeBotId = id;
|
this.activeBotId = id;
|
||||||
// Fetch queue for newly active bot if we don't have it yet
|
// Fetch queue for newly active bot if we don't have it yet
|
||||||
if (!this.queues[id]) {
|
if (!this.queues[id]) {
|
||||||
@@ -132,6 +142,32 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Lock the UI to a single bot (dedicated link). Sets scope first so the
|
||||||
|
* setActiveBotId guard does not block the switch to the scoped bot. */
|
||||||
|
setScope(id: string) {
|
||||||
|
this.scopedBotId = id;
|
||||||
|
this.activeBotId = id;
|
||||||
|
// Lazily fetch this bot's queue, mirroring setActiveBotId.
|
||||||
|
if (!this.queues[id]) {
|
||||||
|
this.fetchQueue();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearScope() {
|
||||||
|
this.scopedBotId = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reconcile the scope with the desired id from the URL (?bot). A stale or
|
||||||
|
* forbidden id resolves to null and clears the scope rather than locking. */
|
||||||
|
applyScopeFromQuery(requestedId: string | null) {
|
||||||
|
const r = resolveScopedBot(requestedId, this.bots.map((b) => b.id));
|
||||||
|
if (r) {
|
||||||
|
this.setScope(r);
|
||||||
|
} else if (requestedId) {
|
||||||
|
this.clearScope();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
updateBotStatus(botId: string, status: BotStatus) {
|
updateBotStatus(botId: string, status: BotStatus) {
|
||||||
const prev = this.bots.find((b) => b.id === botId);
|
const prev = this.bots.find((b) => b.id === botId);
|
||||||
const prevSongId = prev?.currentSong?.id;
|
const prevSongId = prev?.currentSong?.id;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolveScopedBot } from "./scope.js";
|
||||||
|
|
||||||
|
describe("resolveScopedBot", () => {
|
||||||
|
it("returns null when no id requested", () => {
|
||||||
|
expect(resolveScopedBot(null, ["a", "b"])).toBeNull();
|
||||||
|
expect(resolveScopedBot(undefined, ["a"])).toBeNull();
|
||||||
|
expect(resolveScopedBot("", ["a"])).toBeNull();
|
||||||
|
});
|
||||||
|
it("returns the id when it exists in the bot list", () => {
|
||||||
|
expect(resolveScopedBot("b", ["a", "b"])).toBe("b");
|
||||||
|
});
|
||||||
|
it("clears (null) when the requested id is not a known bot", () => {
|
||||||
|
expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Given the desired scoped id (from ?bot) and the known bot ids, decide the
|
||||||
|
* effective scope. Returns the id if it exists, else null (graceful clear:
|
||||||
|
* a stale/forbidden id never locks the UI). */
|
||||||
|
export function resolveScopedBot(
|
||||||
|
requestedId: string | null | undefined,
|
||||||
|
knownBotIds: readonly string[],
|
||||||
|
): string | null {
|
||||||
|
if (!requestedId) return null;
|
||||||
|
return knownBotIds.includes(requestedId) ? requestedId : null;
|
||||||
|
}
|
||||||
@@ -22,8 +22,8 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
const bot = store.bots.find((b) => b.id === botId);
|
const bot = store.bots.find((b) => b.id === botId);
|
||||||
if (bot) {
|
if (bot) {
|
||||||
store.setActiveBotId(botId);
|
store.setScope(botId);
|
||||||
router.replace('/');
|
router.replace({ path: '/', query: { bot: botId } });
|
||||||
} else {
|
} else {
|
||||||
notFound.value = true;
|
notFound.value = true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user