From 827867adfc91fab43ecaa3383c241272fb9a58d6 Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Sat, 2 Aug 2025 14:46:58 +0100 Subject: [PATCH 01/17] Content category fixes, frontpage and card view rendering fixes --- package.json | 2 +- .../admin/maintenanceManagement.vue | 48 ++++++- .../src/components/home/featuredSlider.vue | 17 ++- .../src/components/releases/albumViewer.vue | 28 ++++- .../src/components/releases/releaseForm.vue | 118 ++++++++++++++---- .../renderer/src/plugins/lensService/hooks.ts | 2 + packages/renderer/src/plugins/router.ts | 16 ++- packages/renderer/src/views/categoryPage.vue | 4 +- packages/renderer/src/views/releasePage.vue | 16 ++- pnpm-lock.yaml | 10 +- 10 files changed, 216 insertions(+), 45 deletions(-) diff --git a/package.json b/package.json index b20bf654..fa258623 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ }, "dependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.2.3", - "@riffcc/lens-sdk": "^0.1.32", + "@riffcc/lens-sdk": "file:../lens-sdk", "@tanstack/vue-query": "^5.81.2", "@vueuse/core": "^12.8.2", "core-js": "^3.43.0", diff --git a/packages/renderer/src/components/admin/maintenanceManagement.vue b/packages/renderer/src/components/admin/maintenanceManagement.vue index 1992d886..13974e53 100644 --- a/packages/renderer/src/components/admin/maintenanceManagement.vue +++ b/packages/renderer/src/components/admin/maintenanceManagement.vue @@ -113,7 +113,7 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de51225d..41e8538e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: is-ipfs: specifier: ^8.0.4 version: 8.0.4 + jsmediatags: + specifier: ^3.9.7 + version: 3.9.7 luxon: specifier: ^3.6.1 version: 3.6.1 @@ -3428,6 +3431,10 @@ packages: engines: {node: '>=6'} hasBin: true + jsmediatags@3.9.7: + resolution: {integrity: sha512-xCAO8C3li3t5hYkXqn8iv8zQQUB4T1QqRN2aSONHMls21ICdEvXi4xtb6W70/fAFYSDwMHd32hIqvo4YuXoNcQ==} + engines: {node: '>=4.0.0'} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -5345,6 +5352,10 @@ packages: utf-8-validate: optional: true + xhr2@0.1.4: + resolution: {integrity: sha512-3QGhDryRzTbIDj+waTRvMBe8SyPhW79kz3YnNb+HQt/6LPYQT3zT3Jt0Y8pBofZqQX26x8Ecfv0FXR72uH5VpA==} + engines: {node: '>= 0.6'} + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -9690,6 +9701,10 @@ snapshots: jsesc@3.1.0: {} + jsmediatags@3.9.7: + dependencies: + xhr2: 0.1.4 + json-buffer@3.0.1: {} json-parse-better-errors@1.0.2: {} @@ -11849,6 +11864,8 @@ snapshots: ws@8.18.3: {} + xhr2@0.1.4: {} + xml-name-validator@4.0.0: {} xmlbuilder@15.1.1: {} From f84f827f822df7c9296d3e65d9e147c9a7225cf9 Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Sat, 2 Aug 2025 16:42:12 +0100 Subject: [PATCH 03/17] Match categories by UUID on frontpage --- packages/renderer/src/views/homePage.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/renderer/src/views/homePage.vue b/packages/renderer/src/views/homePage.vue index e5c6389c..e59fb896 100644 --- a/packages/renderer/src/views/homePage.vue +++ b/packages/renderer/src/views/homePage.vue @@ -131,7 +131,8 @@ const activeSections = computed(() => { return contentCategories.value .filter(category => category.featured) .map(featuredCategory => { - const items = releasesByCategory.get(featuredCategory.categoryId) || []; + // Use the category's ID (UUID) to match releases, not the slug + const items = releasesByCategory.get(featuredCategory.id) || []; return { id: featuredCategory.categoryId, title: featuredCategory.categoryId === 'tv-shows' ? featuredCategory.displayName : `Featured ${featuredCategory.displayName}`, From b5dcd1d8e00d1bf4d710ae13ab53821e6bf96796 Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Fri, 1 Aug 2025 19:54:43 +0100 Subject: [PATCH 04/17] Docker improvements --- .browserslistrc | 2 +- .claude/settings.local.json | 8 +++++++- Dockerfile | 9 ++++++--- lens-sdk | 1 + package.json | 1 + pnpm-lock.yaml | 3 +++ 6 files changed, 19 insertions(+), 5 deletions(-) create mode 120000 lens-sdk diff --git a/.browserslistrc b/.browserslistrc index 39891dff..8e4ca0f9 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1 +1 @@ -Chrome 132 \ No newline at end of file +Chrome 124 \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2029c429..47e37527 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,5 +18,11 @@ "Bash(pnpm typecheck:*)" ], "deny": [] - } + }, + "enabledMcpjsonServers": [ + "playwright", + "deepwiki" + ], + "enableAllProjectMcpServers": true, + "$schema": "https://json.schemastore.org/claude-code-settings.json" } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d8e4a6e2..2124faa3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,13 +14,16 @@ WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ # Install dependencies -RUN pnpm install --frozen-lockfile --ignore-scripts +RUN pnpm install --frozen-lockfile --ignore-scripts --unsafe-perm # Copy source code COPY . . +# Generate Electron vendors file +RUN node scripts/update-electron-vendors.mjs + # Build the web version -RUN pnpm compile:web +RUN pnpm build # Production stage FROM nginx:alpine @@ -43,4 +46,4 @@ RUN echo 'server { \ EXPOSE 80 # Start nginx -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["nginx", "-g", "daemon off;"] diff --git a/lens-sdk b/lens-sdk new file mode 120000 index 00000000..f3b70753 --- /dev/null +++ b/lens-sdk @@ -0,0 +1 @@ +../lens-sdk \ No newline at end of file diff --git a/package.json b/package.json index 0770c74a..e04547c5 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "dependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@riffcc/lens-sdk": "file:../lens-sdk", + "@multiformats/multiaddr": "^12.5.1", "@tanstack/vue-query": "^5.81.2", "@vueuse/core": "^12.8.2", "core-js": "^3.43.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41e8538e..38b25373 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@esbuild-plugins/node-globals-polyfill': specifier: ^0.2.3 version: 0.2.3(esbuild@0.25.5) + '@multiformats/multiaddr': + specifier: ^12.5.1 + version: 12.5.1 '@riffcc/lens-sdk': specifier: file:../lens-sdk version: file:../lens-sdk(react-native@0.80.2(@babel/core@7.28.0)(react@19.1.1)) From 7bdfaf1b9ab601a6518bdf0005a0f4f05aac88a7 Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Sat, 2 Aug 2025 09:21:44 +0100 Subject: [PATCH 05/17] Docker fixes --- .claude/settings.local.json | 9 +++++---- Dockerfile => docker/Dockerfile | 12 ++---------- docker/nginx.conf | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 14 deletions(-) rename Dockerfile => docker/Dockerfile (74%) create mode 100644 docker/nginx.conf diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 47e37527..65f4c55c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,4 +1,5 @@ { + "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { "allow": [ "Bash(docker build:*)", @@ -15,14 +16,14 @@ "Bash(pnpm run lint:*)", "WebFetch(domain:opencollective.com)", "Bash(pnpm i:*)", - "Bash(pnpm typecheck:*)" + "Bash(pnpm typecheck:*)", + "mcp__playwright__browser_navigate" ], "deny": [] }, + "enableAllProjectMcpServers": true, "enabledMcpjsonServers": [ "playwright", "deepwiki" - ], - "enableAllProjectMcpServers": true, - "$schema": "https://json.schemastore.org/claude-code-settings.json" + ] } \ No newline at end of file diff --git a/Dockerfile b/docker/Dockerfile similarity index 74% rename from Dockerfile rename to docker/Dockerfile index 2124faa3..3f03bc8f 100644 --- a/Dockerfile +++ b/docker/Dockerfile @@ -31,16 +31,8 @@ FROM nginx:alpine # Copy built files from builder stage COPY --from=builder /app/packages/renderer/dist/web /usr/share/nginx/html -# Copy nginx config if needed for SPA routing -RUN echo 'server { \ - listen 80; \ - server_name localhost; \ - root /usr/share/nginx/html; \ - index index.html; \ - location / { \ - try_files $uri $uri/ /index.html; \ - } \ -}' > /etc/nginx/conf.d/default.conf +# Copy custom nginx config for SPA routing and proper MIME types +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf # Expose port 80 EXPOSE 80 diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 00000000..65ef755e --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Static assets - serve directly or 404 + location ~* \.(js|css|wasm|png|jpg|jpeg|gif|ico|svg|webp|json|txt|xml|webmanifest)$ { + try_files $uri =404; + } + + # SPA fallback - only for routes, not assets + location / { + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file From ae72fce6df978ec2b1dd33b55bc2ef9b8a5edde8 Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Sat, 2 Aug 2025 17:38:04 +0100 Subject: [PATCH 06/17] Rebase UI fixes --- .gitignore | 3 +++ package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 420b4627..c05fcf24 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ playwright-report/ .electron-vendors.cache.json *.txt + +# lens-sdk +lens-sdk diff --git a/package.json b/package.json index e04547c5..1cb72cae 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@riffcc/lens-sdk": "file:../lens-sdk", "@multiformats/multiaddr": "^12.5.1", + "@riffcc/lens-sdk": "file:../lens-sdk", "@tanstack/vue-query": "^5.81.2", "@vueuse/core": "^12.8.2", "core-js": "^3.43.0", From fe938ec1ffec0cb0fb57245e0bc1c4aa0207b0af Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Sat, 2 Aug 2025 17:36:01 +0100 Subject: [PATCH 07/17] Improved releases view - CSS grid --- .claude/settings.local.json | 29 ------- .gitignore | 4 + .../components/misc/infiniteReleaseList.vue | 87 +++++-------------- 3 files changed, 25 insertions(+), 95 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 65f4c55c..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/claude-code-settings.json", - "permissions": { - "allow": [ - "Bash(docker build:*)", - "Bash(pnpm test:*)", - "Bash(grep:*)", - "WebFetch(domain:deepwiki.com)", - "Bash(grep:*)", - "Bash(rg:*)", - "Bash(pnpm build:*)", - "Bash(rm:*)", - "Bash(find:*)", - "Bash(ls:*)", - "Bash(pnpm build:*)", - "Bash(pnpm run lint:*)", - "WebFetch(domain:opencollective.com)", - "Bash(pnpm i:*)", - "Bash(pnpm typecheck:*)", - "mcp__playwright__browser_navigate" - ], - "deny": [] - }, - "enableAllProjectMcpServers": true, - "enabledMcpjsonServers": [ - "playwright", - "deepwiki" - ] -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index c05fcf24..e4222e59 100644 --- a/.gitignore +++ b/.gitignore @@ -48,5 +48,9 @@ playwright-report/ *.txt +<<<<<<< HEAD # lens-sdk lens-sdk +======= +.claude +>>>>>>> a0c28db (Improved releases view - CSS grid) diff --git a/packages/renderer/src/components/misc/infiniteReleaseList.vue b/packages/renderer/src/components/misc/infiniteReleaseList.vue index 306b5c35..8c1bf61d 100644 --- a/packages/renderer/src/components/misc/infiniteReleaseList.vue +++ b/packages/renderer/src/components/misc/infiniteReleaseList.vue @@ -16,32 +16,16 @@ @@ -25,8 +28,11 @@ + + \ No newline at end of file diff --git a/packages/renderer/src/components/releases/audioPlayer.vue b/packages/renderer/src/components/releases/audioPlayer.vue index 01b0edc5..9e4b40a1 100644 --- a/packages/renderer/src/components/releases/audioPlayer.vue +++ b/packages/renderer/src/components/releases/audioPlayer.vue @@ -3,6 +3,7 @@ position="sticky" location="bottom right" class="w-100 border rounded-t-xl mx-auto" + color="black" :elevation="24" height="100px" max-width="960px" @@ -172,12 +173,13 @@ diff --git a/packages/renderer/src/composables/globalPlayback.ts b/packages/renderer/src/composables/globalPlayback.ts new file mode 100644 index 00000000..65b5a7b2 --- /dev/null +++ b/packages/renderer/src/composables/globalPlayback.ts @@ -0,0 +1,56 @@ +import { ref } from 'vue'; + +// Global playback state +const globalPlaybackHandlers = ref<{ + play: (() => void) | null; + pause: (() => void) | null; + togglePlay: (() => void) | null; +}>({ + play: null, + pause: null, + togglePlay: null, +}); + +export function useGlobalPlayback() { + const registerPlaybackHandlers = (handlers: { + play: () => void; + pause: () => void; + togglePlay: () => void; + }) => { + globalPlaybackHandlers.value = handlers; + }; + + const clearPlaybackHandlers = () => { + globalPlaybackHandlers.value = { + play: null, + pause: null, + togglePlay: null, + }; + }; + + const globalTogglePlay = () => { + if (globalPlaybackHandlers.value.togglePlay) { + globalPlaybackHandlers.value.togglePlay(); + } + }; + + const globalPlay = () => { + if (globalPlaybackHandlers.value.play) { + globalPlaybackHandlers.value.play(); + } + }; + + const globalPause = () => { + if (globalPlaybackHandlers.value.pause) { + globalPlaybackHandlers.value.pause(); + } + }; + + return { + registerPlaybackHandlers, + clearPlaybackHandlers, + globalTogglePlay, + globalPlay, + globalPause, + }; +} \ No newline at end of file diff --git a/packages/renderer/src/composables/imageColorExtraction.ts b/packages/renderer/src/composables/imageColorExtraction.ts new file mode 100644 index 00000000..64e95859 --- /dev/null +++ b/packages/renderer/src/composables/imageColorExtraction.ts @@ -0,0 +1,120 @@ +import { ref, type Ref } from 'vue'; + +/** + * Extracts the dominant color from an image and returns a tinted gradient + * Uses Canvas API to sample the image and calculate average color + */ +export function useImageColorExtraction() { + const extractedColors = ref>(new Map()); + + /** + * Extract dominant color from image URL + * Returns a gradient string with the color tinted darker + */ + async function getColorTintedGradient(imageUrl: string | undefined): Promise { + // Default gradient if no image + if (!imageUrl) { + return 'to bottom, rgba(0,0,0,.4), rgba(0,0,0,.41)'; + } + + // Check cache first + const cached = extractedColors.value.get(imageUrl); + if (cached) { + return cached; + } + + try { + // Create an image element + const img = new Image(); + img.crossOrigin = 'anonymous'; + + // Create a promise to wait for image load + const colorPromise = new Promise((resolve) => { + img.onload = () => { + // Create canvas + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + resolve('to bottom, rgba(0,0,0,.4), rgba(0,0,0,.41)'); + return; + } + + // Set canvas size (small for performance) + const sampleSize = 50; + canvas.width = sampleSize; + canvas.height = sampleSize; + + // Draw scaled image + ctx.drawImage(img, 0, 0, sampleSize, sampleSize); + + // Get image data + const imageData = ctx.getImageData(0, 0, sampleSize, sampleSize); + const data = imageData.data; + + // Calculate average color + let r = 0, g = 0, b = 0; + let pixelCount = 0; + + for (let i = 0; i < data.length; i += 4) { + // Skip very dark or very light pixels + const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3; + if (brightness > 20 && brightness < 235) { + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + pixelCount++; + } + } + + if (pixelCount === 0) { + resolve('to bottom, rgba(0,0,0,.4), rgba(0,0,0,.41)'); + return; + } + + // Average the colors + r = Math.round(r / pixelCount); + g = Math.round(g / pixelCount); + b = Math.round(b / pixelCount); + + // Create gradient with darkened tint + // Top: 40% opacity with slight darkening + // Bottom: 60% opacity with more darkening + const topColor = `rgba(${Math.round(r * 0.7)},${Math.round(g * 0.7)},${Math.round(b * 0.7)},.4)`; + const bottomColor = `rgba(${Math.round(r * 0.5)},${Math.round(g * 0.5)},${Math.round(b * 0.5)},.6)`; + + const gradient = `to bottom, ${topColor}, ${bottomColor}`; + resolve(gradient); + }; + + img.onerror = () => { + // Fallback gradient on error + resolve('to bottom, rgba(0,0,0,.4), rgba(0,0,0,.41)'); + }; + }); + + // Start loading the image + img.src = imageUrl; + + // Wait with timeout + const gradient = await Promise.race([ + colorPromise, + new Promise((resolve) => + setTimeout(() => resolve('to bottom, rgba(0,0,0,.4), rgba(0,0,0,.41)'), 2000) + ) + ]); + + // Cache the result + extractedColors.value.set(imageUrl, gradient); + + return gradient; + } catch (error) { + console.error('Error extracting color:', error); + return 'to bottom, rgba(0,0,0,.4), rgba(0,0,0,.41)'; + } + } + + return { + getColorTintedGradient + }; +} \ No newline at end of file diff --git a/packages/renderer/src/composables/useGamepad.ts b/packages/renderer/src/composables/useGamepad.ts index 0faf3257..7d5e65db 100644 --- a/packages/renderer/src/composables/useGamepad.ts +++ b/packages/renderer/src/composables/useGamepad.ts @@ -1,4 +1,5 @@ import { ref, onMounted, onUnmounted, computed } from 'vue'; +import { useInputMethod } from './useInputMethod'; export interface GamepadState { connected: boolean; @@ -31,6 +32,8 @@ const SCROLL_SPEED_MIN = 1; const SCROLL_SPEED_MAX = 20; export function useGamepad() { + const { setInputMethod } = useInputMethod(); + const gamepadState = ref({ connected: false, type: 'generic', @@ -59,6 +62,7 @@ export function useGamepad() { const gamepadIndex = ref(null); const previousButtonStates = new Map(); const buttonCallbacks = new Map void>(); + let hasRecentInput = false; // Detect gamepad type from ID function detectGamepadType(id: string): 'xbox' | 'playstation' | 'switch' | 'generic' { @@ -108,15 +112,17 @@ export function useGamepad() { // } // Update stick positions with dead zone - gamepadState.value.leftStick = { - x: applyDeadZone(gamepad.axes[0]), - y: applyDeadZone(gamepad.axes[1]), - }; + const leftX = applyDeadZone(gamepad.axes[0]); + const leftY = applyDeadZone(gamepad.axes[1]); + const rightX = applyDeadZone(gamepad.axes[2]); + const rightY = applyDeadZone(gamepad.axes[3]); - gamepadState.value.rightStick = { - x: applyDeadZone(gamepad.axes[2]), - y: applyDeadZone(gamepad.axes[3]), - }; + gamepadState.value.leftStick = { x: leftX, y: leftY }; + gamepadState.value.rightStick = { x: rightX, y: rightY }; + + // Check if there's any gamepad input + const hasStickInput = Math.abs(leftX) > 0 || Math.abs(leftY) > 0 || + Math.abs(rightX) > 0 || Math.abs(rightY) > 0; // Map buttons (standard gamepad mapping) const buttonMappings = { @@ -138,6 +144,9 @@ export function useGamepad() { right: gamepad.buttons[15], }; + // Check if any button is pressed + let hasButtonInput = false; + // Update button states and trigger callbacks Object.entries(buttonMappings).forEach(([key, button]) => { if (!button) return; // Skip if button doesn't exist @@ -149,9 +158,11 @@ export function useGamepad() { // Apply dead zone to triggers const triggerValue = value > TRIGGER_DEAD_ZONE ? value : 0; (gamepadState.value.buttons as any)[key] = triggerValue; + if (triggerValue > 0) hasButtonInput = true; } else { const wasPressed = previousButtonStates.get(key) || false; (gamepadState.value.buttons as any)[key] = pressed; + if (pressed) hasButtonInput = true; // Trigger callback on button press (not release) if (pressed && !wasPressed && buttonCallbacks.has(key)) { @@ -161,6 +172,16 @@ export function useGamepad() { previousButtonStates.set(key, pressed); } }); + + // Set input method to gamepad if there's any input + if (hasStickInput || hasButtonInput) { + if (!hasRecentInput) { + setInputMethod('gamepad'); + hasRecentInput = true; + } + } else { + hasRecentInput = false; + } } // Computed values for easier access diff --git a/packages/renderer/src/composables/useGamepadNavigation.ts b/packages/renderer/src/composables/useGamepadNavigation.ts index 98946cac..e625ca96 100644 --- a/packages/renderer/src/composables/useGamepadNavigation.ts +++ b/packages/renderer/src/composables/useGamepadNavigation.ts @@ -12,14 +12,17 @@ interface NavigableElement { height: number; } +// Navigation lock for modal dialogs +const navigationLocked = ref(false); + export function useGamepadNavigation() { const router = useRouter(); const { gamepadState, onButtonPress } = useGamepad(); const focusedElement = ref(null); const navigableElements = ref([]); - const lastNavigationTime = ref(0); - const NAVIGATION_DELAY = 200; // ms between navigation actions + const lastStickDirection = ref({ x: 0, y: 0 }); + const lastDpadState = ref({ up: false, down: false, left: false, right: false }); // Custom cursor for right stick const cursorPosition = ref({ x: window.innerWidth / 2, y: window.innerHeight / 2 }); @@ -37,17 +40,43 @@ export function useGamepadNavigation() { // Update navigable elements function updateNavigableElements() { - const elements = document.querySelectorAll('[data-navigable="true"], a, button, [role="button"], .content-card'); - navigableElements.value = Array.from(elements).map(el => { - const rect = (el as HTMLElement).getBoundingClientRect(); - return { - element: el as HTMLElement, - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - width: rect.width, - height: rect.height, - }; - }); + // If navigation is locked, only look for elements in the modal + if (navigationLocked.value) { + const elements = document.querySelectorAll('.start-menu-card [data-navigable="true"], .start-menu-card .v-list-item'); + navigableElements.value = Array.from(elements).map(el => { + const rect = (el as HTMLElement).getBoundingClientRect(); + return { + element: el as HTMLElement, + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + width: rect.width, + height: rect.height, + }; + }); + } else { + const elements = document.querySelectorAll('[data-navigable="true"], a[href], button:not(:disabled), [role="button"], .content-card[cursor-pointer], .v-btn:not(:disabled), .v-list-item'); + navigableElements.value = Array.from(elements) + .filter(el => { + const element = el as HTMLElement; + // Filter out non-interactive elements + if (element.tagName === 'DIV' && !element.classList.contains('content-card') && !element.hasAttribute('data-navigable')) { + return false; + } + // Make sure element is visible + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }) + .map(el => { + const rect = (el as HTMLElement).getBoundingClientRect(); + return { + element: el as HTMLElement, + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + width: rect.width, + height: rect.height, + }; + }); + } } // Find nearest element in a direction @@ -105,11 +134,11 @@ export function useGamepadNavigation() { // Handle left stick navigation watch([() => gamepadState.value.leftStick, () => gamepadState.value.buttons], ([stick, buttons]) => { - const now = Date.now(); - if (now - lastNavigationTime.value < NAVIGATION_DELAY) return; + // Skip navigation if locked (modal is open) + if (navigationLocked.value) return; // Update control method and hide cursor when using left stick - if (Math.abs(stick.x) > 0.1 || Math.abs(stick.y) > 0.1 || + if (Math.abs(stick.x) > 0.3 || Math.abs(stick.y) > 0.3 || buttons.up || buttons.down || buttons.left || buttons.right) { lastControlMethod.value = 'leftStick'; showCursor.value = false; @@ -119,7 +148,7 @@ export function useGamepadNavigation() { } } - // D-pad navigation + // D-pad navigation (instant snap) if (buttons.up) { const element = findNearestElement('up'); if (element) { @@ -146,10 +175,11 @@ export function useGamepadNavigation() { } } - // Analog stick navigation - if (Math.abs(stick.x) > 0.5 || Math.abs(stick.y) > 0.5) { + // Left stick navigation (snap to elements, no free movement) + if (Math.abs(stick.x) > 0.6 || Math.abs(stick.y) > 0.6) { let direction: 'up' | 'down' | 'left' | 'right'; + // Determine primary direction with stronger threshold if (Math.abs(stick.x) > Math.abs(stick.y)) { direction = stick.x > 0 ? 'right' : 'left'; } else { @@ -162,12 +192,6 @@ export function useGamepadNavigation() { lastNavigationTime.value = now; } } - - // Scroll with variable speed - if (Math.abs(stick.y) > 0.1) { - const scrollSpeed = 10 + Math.abs(stick.y) * 40; // 10-50px per frame - window.scrollBy(0, stick.y * scrollSpeed); - } }); // Handle right stick cursor @@ -264,10 +288,11 @@ export function useGamepadNavigation() { // Removed - R3 is now used for play/pause - onButtonPress('start', () => { - // Open menu - router.push('/menu'); - }); + // Start button is handled in App.vue for the start menu overlay + // onButtonPress('start', () => { + // // Open menu + // router.push('/menu'); + // }); // L3/R3 for play/pause const playPauseHandler = () => { @@ -347,10 +372,23 @@ export function useGamepadNavigation() { cursor?.remove(); }); + // Lock/unlock navigation for modal dialogs + function lockNavigation() { + navigationLocked.value = true; + updateNavigableElements(); + } + + function unlockNavigation() { + navigationLocked.value = false; + updateNavigableElements(); + } + return { focusedElement, showCursor, cursorPosition, updateNavigableElements, + lockNavigation, + unlockNavigation, }; } \ No newline at end of file diff --git a/packages/renderer/src/composables/useInputMethod.ts b/packages/renderer/src/composables/useInputMethod.ts new file mode 100644 index 00000000..0591c923 --- /dev/null +++ b/packages/renderer/src/composables/useInputMethod.ts @@ -0,0 +1,64 @@ +import { ref, onMounted, onUnmounted } from 'vue'; + +export type InputMethod = 'mouse' | 'gamepad' | 'keyboard'; + +const currentInputMethod = ref('mouse'); +let lastMousePosition = { x: 0, y: 0 }; +let mouseCheckInterval: number | undefined; + +export function useInputMethod() { + function setInputMethod(method: InputMethod) { + if (currentInputMethod.value !== method) { + currentInputMethod.value = method; + updateBodyClass(method); + } + } + + function updateBodyClass(method: InputMethod) { + document.body.classList.remove('input-mouse', 'input-gamepad', 'input-keyboard'); + document.body.classList.add(`input-${method}`); + } + + // Detect mouse movement + function handleMouseMove(e: MouseEvent) { + const moved = Math.abs(e.clientX - lastMousePosition.x) > 5 || + Math.abs(e.clientY - lastMousePosition.y) > 5; + + if (moved) { + lastMousePosition = { x: e.clientX, y: e.clientY }; + setInputMethod('mouse'); + } + } + + // Detect keyboard input + function handleKeyboard(e: KeyboardEvent) { + // Ignore gamepad-simulated keyboard events + if (!e.isTrusted) return; + setInputMethod('keyboard'); + } + + onMounted(() => { + // Initialize body class + updateBodyClass(currentInputMethod.value); + + // Add event listeners + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('keydown', handleKeyboard); + + // Store initial mouse position + lastMousePosition = { x: window.innerWidth / 2, y: window.innerHeight / 2 }; + }); + + onUnmounted(() => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('keydown', handleKeyboard); + if (mouseCheckInterval) { + clearInterval(mouseCheckInterval); + } + }); + + return { + currentInputMethod, + setInputMethod, + }; +} \ No newline at end of file diff --git a/packages/renderer/src/composables/userSession.ts b/packages/renderer/src/composables/userSession.ts index 23a21d34..55583efc 100644 --- a/packages/renderer/src/composables/userSession.ts +++ b/packages/renderer/src/composables/userSession.ts @@ -11,15 +11,11 @@ const userData: Ref = ref(null); export const useUserSession = () => { onMounted(async () => { - const svg = await [ - import('../../public/undraw/undraw_pic_profile_re_7g2h.svg'), - import('../../public/undraw/undraw_profile_pic_re_iwgo.svg'), - ][Math.floor(Math.random() * 2)]; userData.value = { id: '1', name: 'Test User', email: 'testing@riff.cc', - avatar: svg.default, + avatar: '', // Let the accountMenu component handle the default avatar }; }); diff --git a/packages/renderer/src/views/homePage.vue b/packages/renderer/src/views/homePage.vue index e59fb896..b14f9257 100644 --- a/packages/renderer/src/views/homePage.vue +++ b/packages/renderer/src/views/homePage.vue @@ -1,5 +1,5 @@ diff --git a/packages/renderer/src/components/releases/videoPlayer.vue b/packages/renderer/src/components/releases/videoPlayer.vue index c0dcfe36..0f747b60 100644 --- a/packages/renderer/src/components/releases/videoPlayer.vue +++ b/packages/renderer/src/components/releases/videoPlayer.vue @@ -36,6 +36,7 @@ + + + + + +
+ mdi-alert-circle-outline +

Unable to play this video

+

+ We don't appear to support playing this video in your browser.
+ Stay tuned, we'll hopefully fix it soon. +

+

+ {{ codecErrorDetails }} +

+
+
+ - +
@@ -75,7 +75,11 @@ const MAGIC_KEY = 'magicmagic'; const yetToType = ref(MAGIC_KEY); onKeyStroke(e => { - if (!yetToType.value.length) return; + if (!yetToType.value.length) { + // Reset for next time + yetToType.value = MAGIC_KEY; + return; + } if (e.key === yetToType.value[0]) { yetToType.value = yetToType.value.slice(1); } else { @@ -84,25 +88,12 @@ onKeyStroke(e => { }); watchEffect(() => { - if (!yetToType.value.length) showDefederation.value = true; -}); - -const CURTAIN_KEY = 'curtain'; -const yetToTypeCurtain = ref(CURTAIN_KEY); - -onKeyStroke(e => { - if (!yetToTypeCurtain.value.length) return; - if (e.key === yetToTypeCurtain.value[0]) { - yetToTypeCurtain.value = yetToTypeCurtain.value.slice(1); - } else { - yetToTypeCurtain.value = CURTAIN_KEY; + if (!yetToType.value.length) { + // Toggle defederation view when magic key is fully typed + showDefederation.value = !showDefederation.value; } }); -watchEffect(() => { - if (!yetToTypeCurtain.value.length) showDefederation.value = false; -}); - onMounted(async () => { initLensService(); @@ -133,6 +124,29 @@ const { data: featuredReleases } = useGetFeaturedReleasesQuery({ const { data: contentCategories } = useContentCategoriesQuery({ enabled: isLensReady, }); + +// Delay showing spinner for 100ms to make app feel faster +const showSpinner = ref(false); +let spinnerTimer: ReturnType | null = null; + +onMounted(() => { + spinnerTimer = setTimeout(() => { + if (!isLensReady.value && !releases.value && !featuredReleases.value && !contentCategories.value) { + showSpinner.value = true; + } + }, 100); +}); + +// Clear timer if data loads quickly +watchEffect(() => { + if (isLensReady.value || releases.value || featuredReleases.value || contentCategories.value) { + if (spinnerTimer) { + clearTimeout(spinnerTimer); + spinnerTimer = null; + } + showSpinner.value = false; + } +}); \ No newline at end of file From a371540c43e8fddbced6cacc28d6ab8799a7e3fd Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Mon, 4 Aug 2025 07:06:38 +0100 Subject: [PATCH 14/17] Update structures panel --- CLAUDE.md | 28 + docs/STRUCTURES.md | 170 +++ package.json | 3 +- .../src/components/admin/structureForm.vue | 303 +++++ .../components/admin/structuresManagement.vue | 1041 +++++++++++++++++ .../src/components/misc/contentCard.vue | 32 +- packages/renderer/src/views/adminPage.vue | 12 + pnpm-lock.yaml | 8 + 8 files changed, 1593 insertions(+), 4 deletions(-) create mode 100644 docs/STRUCTURES.md create mode 100644 packages/renderer/src/components/admin/structureForm.vue create mode 100644 packages/renderer/src/components/admin/structuresManagement.vue diff --git a/CLAUDE.md b/CLAUDE.md index 2b753fb3..568dc1c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,34 @@ Flagship is Riff.CC's decentralized media platform for watching, sharing, and cu - Vite configs in each package control build behavior - Content is distributed via P2P network with configurable replication factors +## CRITICAL DATA STRUCTURE NOTES + +### Category and Release Structure +**Category object** has: +- `id` - The hash/unique identifier for the category +- `slug` - The slug identifier (e.g., 'tv-shows', 'music', 'movies') + +**Release object** has: +- `categoryId` - References the category's hash ID +- `categorySlug` - References the category's slug + +When filtering releases by category type, use `categorySlug` on the release, NOT `categoryId`! + +### Structures System +Structures are completely generic organizational containers documented in `docs/STRUCTURES.md`. They can represent ANY hierarchical relationship - artists/albums, TV shows/seasons, book series/volumes, courses/lessons, etc. The system is designed for efficient PeerBit queries across arbitrary hierarchies using `parentId` relationships and content references via `metadata.structureId`. + +## Key Implementation Details + +### Series and Episodes +- Series structures should only exist when they have actual episodes +- Episodes link to series via `metadata.seriesId` matching the series structure's `id` +- Seasons are tracked via `metadata.seasonNumber` on episodes + +### Important Reminders +- Check this file before making assumptions about data structures +- When something works on one page but not another, the issue is usually simple +- Focus on fixing exactly what's requested without adding complexity + ## Important Instructions - NEVER use git checkout to revert changes - this will throw away hours of work - Always manually revert specific changes using the Edit tool diff --git a/docs/STRUCTURES.md b/docs/STRUCTURES.md new file mode 100644 index 00000000..7bc9a295 --- /dev/null +++ b/docs/STRUCTURES.md @@ -0,0 +1,170 @@ +# Structures Documentation + +This document details the structure system in Riff.CC - a generic hierarchical organization system for ANY type of content. + +## Overview + +Structures are completely generic containers that can represent ANY organizational entity and form arbitrary hierarchies. They are designed to enable efficient PeerBit queries across complex relationships. + +A structure can be: +- **Artist** (containing albums, songs, collaborations) +- **Author** (containing book series, individual books) +- **Director** (containing filmographies, TV shows) +- **Actor** (containing performances across different media) +- **TV Show** (containing seasons) +- **Season** (containing episodes) +- **Album** (containing tracks) +- **Book Series** (containing volumes) +- **Course** (containing lessons) +- **Any other organizational concept** + +## Data Model + +### Structure Schema + +```typescript +interface Structure { + id: string; // Unique identifier + type: string; // Completely arbitrary - 'artist', 'album', 'tv-show', 'season', 'course', etc. + title: string; // Display name + description?: string; // Optional description + thumbnail?: string; // CID for thumbnail image + parentId?: string; // References parent structure (completely optional) + categoryId?: string; // Optional reference to content category + categorySlug?: string; // Optional category slug + createdAt: Date; + updatedAt: Date; +} +``` + +### Content Relationship + +Content (releases) can reference structures through metadata: + +```typescript +interface Release { + // ... standard release fields + metadata: { + structureId?: string; // References ANY structure + parentStructureId?: string; // References parent structure + // ... other metadata + }; +} +``` + +## Hierarchy Examples + +### Music Organization +``` +Artist: "Radiohead" (type: 'artist') +├── Album: "OK Computer" (type: 'album', parentId: radiohead-id) +│ ├── Track: "Paranoid Android" (metadata.structureId: ok-computer-id) +│ └── Track: "Karma Police" (metadata.structureId: ok-computer-id) +└── Album: "In Rainbows" (type: 'album', parentId: radiohead-id) + ├── Track: "15 Step" (metadata.structureId: in-rainbows-id) + └── Track: "Bodysnatchers" (metadata.structureId: in-rainbows-id) +``` + +### TV Organization +``` +TV Show: "Breaking Bad" (type: 'tv-show') +├── Season: "Season 1" (type: 'season', parentId: breaking-bad-id) +│ ├── Episode: "Pilot" (metadata.structureId: season-1-id) +│ └── Episode: "Cat's in the Bag..." (metadata.structureId: season-1-id) +└── Season: "Season 2" (type: 'season', parentId: breaking-bad-id) +``` + +### Book Series Organization +``` +Author: "J.K. Rowling" (type: 'author') +└── Series: "Harry Potter" (type: 'book-series', parentId: jk-rowling-id) + ├── Book: "Philosopher's Stone" (metadata.structureId: harry-potter-id) + └── Book: "Chamber of Secrets" (metadata.structureId: harry-potter-id) +``` + +## Query Patterns + +The power of structures lies in efficient PeerBit queries across hierarchical relationships: + +### Find all content under a structure +```typescript +// Find all tracks in an album +const tracks = await site.releases.query({ + 'metadata.structureId': albumId +}); + +// Find all episodes in a season +const episodes = await site.releases.query({ + 'metadata.structureId': seasonId +}); +``` + +### Find all child structures +```typescript +// Find all albums by an artist +const albums = await site.structures.query({ + parentId: artistId +}); + +// Find all seasons of a TV show +const seasons = await site.structures.query({ + parentId: tvShowId +}); +``` + +### Multi-level queries +```typescript +// Find everything by an artist (albums + standalone tracks) +const artistAlbums = await site.structures.query({ parentId: artistId }); +const directTracks = await site.releases.query({ 'metadata.structureId': artistId }); + +// Get all tracks from all albums +const allAlbumTracks = []; +for (const album of artistAlbums) { + const tracks = await site.releases.query({ 'metadata.structureId': album.id }); + allAlbumTracks.push(...tracks); +} +``` + +## Key Design Principles + +1. **Completely Generic**: No hardcoded content types or relationships +2. **Arbitrary Hierarchies**: Any structure can be parent/child of any other +3. **Efficient P2P Queries**: Designed for optimal PeerBit query performance +4. **Optional Relationships**: All relationships are optional - structures can be standalone +5. **Flexible Metadata**: Content can reference structures however makes sense + +## Common Patterns + +### Standalone Content +Content doesn't need to belong to any structure: +```typescript +// A standalone documentary +{ + title: "Free Culture Documentary", + // no metadata.structureId - completely independent +} +``` + +### Multiple Structure References +Content can reference multiple structures: +```typescript +// A song that's part of an album AND a compilation +{ + title: "Bohemian Rhapsody", + metadata: { + structureId: albumId, // Part of "A Night at the Opera" + compilationIds: [comp1, comp2] // Also in various compilations + } +} +``` + +### Cross-Category Structures +Structures can span different content categories: +```typescript +// A director structure containing both movies and TV shows +Director: "Christopher Nolan" (type: 'director') +├── Movie: "Inception" (categorySlug: 'movies', metadata.structureId: nolan-id) +├── Movie: "Interstellar" (categorySlug: 'movies', metadata.structureId: nolan-id) +└── TV Show: "Westworld" (categorySlug: 'tv-shows', metadata.structureId: nolan-id) +``` \ No newline at end of file diff --git a/package.json b/package.json index e04547c5..f3c5e14c 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@rollup/plugin-node-resolve": "^16.0.1", "@types/luxon": "^3.6.2", "@types/node": "^22.15.33", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.35.0", "@typescript-eslint/parser": "^8.35.0", "@vitejs/plugin-vue": "^5.2.4", @@ -71,8 +72,8 @@ }, "dependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.2.3", - "@riffcc/lens-sdk": "file:../lens-sdk", "@multiformats/multiaddr": "^12.5.1", + "@riffcc/lens-sdk": "file:../lens-sdk", "@tanstack/vue-query": "^5.81.2", "@vueuse/core": "^12.8.2", "core-js": "^3.43.0", diff --git a/packages/renderer/src/components/admin/structureForm.vue b/packages/renderer/src/components/admin/structureForm.vue new file mode 100644 index 00000000..4768428c --- /dev/null +++ b/packages/renderer/src/components/admin/structureForm.vue @@ -0,0 +1,303 @@ + + + \ No newline at end of file diff --git a/packages/renderer/src/components/admin/structuresManagement.vue b/packages/renderer/src/components/admin/structuresManagement.vue new file mode 100644 index 00000000..fd3a3f55 --- /dev/null +++ b/packages/renderer/src/components/admin/structuresManagement.vue @@ -0,0 +1,1041 @@ + + + + + \ No newline at end of file diff --git a/packages/renderer/src/components/misc/contentCard.vue b/packages/renderer/src/components/misc/contentCard.vue index 37666e4b..c9588878 100644 --- a/packages/renderer/src/components/misc/contentCard.vue +++ b/packages/renderer/src/components/misc/contentCard.vue @@ -179,12 +179,25 @@ const cardHeight = computed(() => { const cardTitle = computed(() => { const categoryId = props.item.categoryId; + const metadata = props.item.metadata; + if (categoryId === 'music') { return props.item.name; } - if (categoryId === 'tvShow') { + + // For TV content - check if it's a series or an episode + if (categoryId === 'tvShow' || metadata?.seriesId) { + // If it's a series tile (has isSeries flag) + if (metadata?.isSeries) { + return props.item.name; + } + // If it's an episode, show the series name if available + if (metadata?.seriesName) { + return metadata.seriesName; + } return props.item.name; } + if (categoryId === 'movie') { return props.item.name; } @@ -193,12 +206,25 @@ const cardTitle = computed(() => { const cardSubtitle = computed(() => { const categoryId = props.item.categoryId; + const metadata = props.item.metadata; + if (categoryId === 'music') { return props.item.metadata?.['author'] ?? ''; } - if (categoryId === 'tvShow') { - return props.item.metadata?.['seasons'] ? `${props.item.metadata['seasons']} Seasons` : undefined; + + // For TV content - check if it's a series or an episode + if (categoryId === 'tvShow' || metadata?.seriesId) { + // If it's a series tile + if (metadata?.isSeries) { + return metadata?.['seasons'] ? `${metadata['seasons']} Seasons` : undefined; + } + // If it's an episode, show season and episode info + if (metadata?.seasonNumber !== undefined && metadata?.episodeNumber !== undefined) { + return `S${String(metadata.seasonNumber).padStart(2, '0')}E${String(metadata.episodeNumber).padStart(2, '0')}: ${props.item.name}`; + } + return props.item.name; } + // Default if (categoryId === 'movie') { return props.item.metadata?.['releaseYear'] ? `(${props.item.metadata['releaseYear']})` : undefined; diff --git a/packages/renderer/src/views/adminPage.vue b/packages/renderer/src/views/adminPage.vue index 019c057d..f35f919d 100644 --- a/packages/renderer/src/views/adminPage.vue +++ b/packages/renderer/src/views/adminPage.vue @@ -47,6 +47,12 @@ > Categories + + Structures + + + + @@ -110,6 +121,7 @@ import featuredManagement from '/@/components/admin/featuredManagement.vue'; import subscriptionManagement from '/@/components/admin/subscriptionManagement.vue'; import siteManagement from '/@/components/admin/siteManagement.vue'; import categoriesManagement from '/@/components/admin/categoriesManagement.vue'; +import structuresManagement from '/@/components/admin/structuresManagement.vue'; import maintenanceManagement from '/@/components/admin/maintenanceManagement.vue'; import type { PartialFeaturedReleaseItem } from '/@//types'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38b25373..0275b1f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@types/node': specifier: ^22.15.33 version: 22.15.33 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 '@typescript-eslint/eslint-plugin': specifier: ^8.35.0 version: 8.35.0(@typescript-eslint/parser@8.35.0(eslint@9.29.0)(typescript@5.8.3))(eslint@9.29.0)(typescript@5.8.3) @@ -1546,6 +1549,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} @@ -7365,6 +7371,8 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/uuid@10.0.0': {} + '@types/verror@1.10.11': optional: true From 49c950e9f2141e556d45d64bb5b26791bd940b56 Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Mon, 4 Aug 2025 09:39:13 +0100 Subject: [PATCH 15/17] Full defederated filtering, half working TV shows implementation --- .../admin/maintenanceManagement.vue | 135 +++++++++++++++++- .../components/admin/structuresManagement.vue | 104 ++++++++++---- .../components/misc/infiniteReleaseList.vue | 43 +++--- .../renderer/src/plugins/lensService/hooks.ts | 2 +- packages/renderer/src/plugins/vuetify.ts | 8 ++ packages/renderer/src/views/categoryPage.vue | 16 ++- 6 files changed, 250 insertions(+), 58 deletions(-) diff --git a/packages/renderer/src/components/admin/maintenanceManagement.vue b/packages/renderer/src/components/admin/maintenanceManagement.vue index 26a051ae..9c1e787f 100644 --- a/packages/renderer/src/components/admin/maintenanceManagement.vue +++ b/packages/renderer/src/components/admin/maintenanceManagement.vue @@ -65,6 +65,28 @@ + + Cleanup Empty Structures + +

Remove empty structures (TV series, seasons, artists, albums) that have no associated content.

+ + Cleanup Empty Structures + + + {{ cleanupResults.message }} + +
+
+ import { ref } from 'vue'; -import { useGetReleasesQuery, useGetFeaturedReleasesQuery, useAddReleaseMutation, useEditReleaseMutation, useDeleteReleaseMutation, useAddFeaturedReleaseMutation, useEditFeaturedReleaseMutation, useDeleteFeaturedReleaseMutation, useContentCategoriesQuery } from '/@/plugins/lensService/hooks'; +import { useGetReleasesQuery, useGetFeaturedReleasesQuery, useAddReleaseMutation, useEditReleaseMutation, useDeleteReleaseMutation, useAddFeaturedReleaseMutation, useEditFeaturedReleaseMutation, useDeleteFeaturedReleaseMutation, useContentCategoriesQuery, useGetStructuresQuery, useDeleteStructureMutation } from '/@/plugins/lensService/hooks'; import { useSnackbarMessage } from '/@/composables/snackbarMessage'; import type { ReleaseItem } from '/@/types'; const isExporting = ref(false); const isImporting = ref(false); +const isCleaningUp = ref(false); const importMode = ref<'upsert' | 'replace'>('upsert'); const importFile = ref(null); const confirmDialog = ref(false); +const cleanupResults = ref<{ message: string; error: boolean } | null>(null); const { snackbarMessage, showSnackbar, openSnackbar, closeSnackbar } = useSnackbarMessage(); @@ -434,4 +458,113 @@ const performImport = async () => { isImporting.value = false; } }; + +// Structures query and mutation for cleanup +const { data: structures } = useGetStructuresQuery(); +const deleteStructureMutation = useDeleteStructureMutation(); + +// Cleanup empty structures +const cleanupEmptyStructures = async () => { + isCleaningUp.value = true; + cleanupResults.value = null; + + try { + if (!structures.value || !releases.value) { + cleanupResults.value = { message: 'No structures or releases found', error: true }; + return; + } + + let deletedCount = 0; + const errors: string[] = []; + + // Find empty series (series with no episodes) + const tvSeries = structures.value.filter((s: any) => s.type === 'series'); + for (const series of tvSeries) { + const hasEpisodes = releases.value.some((r: any) => + r.metadata?.seriesId === series.id + ); + + if (!hasEpisodes) { + try { + await deleteStructureMutation.mutateAsync(series.id); + deletedCount++; + console.log(`Deleted empty series: ${series.name}`); + } catch (error) { + errors.push(`Failed to delete series ${series.name}: ${error}`); + } + } + } + + // Find empty seasons (seasons with no episodes) + const seasons = structures.value.filter((s: any) => s.type === 'season'); + for (const season of seasons) { + const hasEpisodes = releases.value.some((r: any) => + r.metadata?.seriesId === season.parentId && + r.metadata?.seasonNumber === season.metadata?.seasonNumber + ); + + if (!hasEpisodes) { + try { + await deleteStructureMutation.mutateAsync(season.id); + deletedCount++; + console.log(`Deleted empty season: ${season.name || `Season ${season.metadata?.seasonNumber}`}`); + } catch (error) { + errors.push(`Failed to delete season ${season.name}: ${error}`); + } + } + } + + // Find empty artists (artists with no releases) + const artists = structures.value.filter((s: any) => s.type === 'artist'); + for (const artist of artists) { + const hasReleases = releases.value.some((r: any) => + r.metadata?.artistId === artist.id || r.metadata?.structureId === artist.id + ); + + if (!hasReleases) { + try { + await deleteStructureMutation.mutateAsync(artist.id); + deletedCount++; + console.log(`Deleted empty artist: ${artist.name}`); + } catch (error) { + errors.push(`Failed to delete artist ${artist.name}: ${error}`); + } + } + } + + // Find empty albums (albums with no tracks) + const albums = structures.value.filter((s: any) => s.type === 'album'); + for (const album of albums) { + const hasTracks = releases.value.some((r: any) => + r.metadata?.albumId === album.id || r.metadata?.structureId === album.id + ); + + if (!hasTracks) { + try { + await deleteStructureMutation.mutateAsync(album.id); + deletedCount++; + console.log(`Deleted empty album: ${album.name}`); + } catch (error) { + errors.push(`Failed to delete album ${album.name}: ${error}`); + } + } + } + + if (errors.length > 0) { + cleanupResults.value = { + message: `Deleted ${deletedCount} empty structures. Errors: ${errors.join(', ')}`, + error: true + }; + } else { + cleanupResults.value = { + message: `Successfully deleted ${deletedCount} empty structures`, + error: false + }; + } + } catch (error) { + cleanupResults.value = { message: `Cleanup failed: ${error}`, error: true }; + } finally { + isCleaningUp.value = false; + } +}; diff --git a/packages/renderer/src/components/admin/structuresManagement.vue b/packages/renderer/src/components/admin/structuresManagement.vue index fd3a3f55..a66116a5 100644 --- a/packages/renderer/src/components/admin/structuresManagement.vue +++ b/packages/renderer/src/components/admin/structuresManagement.vue @@ -669,14 +669,25 @@ const allSeasons = computed(() => { return structures.value.filter((s: any) => s.type === 'season'); }); -// Auto-cleanup empty seasons and series -watchEffect(async () => { - if (!releases.value || !structures.value) return; +// Store references to empty structures for manual cleanup +const emptyStructures = computed(() => { + if (!releases.value || !structures.value || !contentCategories.value) return { series: [], seasons: [] }; + + // Get all TV show category IDs (including federated) + const tvCategoryIds = new Set(); + contentCategories.value.forEach(cat => { + if (cat.categoryId === 'tv-shows') { + tvCategoryIds.add(cat.id); + if (cat.allIds) { + cat.allIds.forEach(id => tvCategoryIds.add(id)); + } + } + }); // Find empty seasons (seasons with no episodes) const emptySeasons = allSeasons.value.filter(season => { const episodeCount = releases.value?.filter((r: any) => - r.categorySlug === 'tv-shows' && + tvCategoryIds.has(r.categoryId) && r.metadata?.seriesId === season.parentId && r.metadata?.seasonNumber === season.metadata?.seasonNumber ).length || 0; @@ -684,30 +695,15 @@ watchEffect(async () => { return episodeCount === 0; }); - // Delete empty seasons - for (const emptySeason of emptySeasons) { - console.log(`Deleting empty season: ${emptySeason.name || `Season ${emptySeason.metadata?.seasonNumber}`}`); - try { - await deleteStructureMutation.mutateAsync(emptySeason.id); - } catch (error) { - console.error('Failed to delete empty season:', error); - } - } - // Find empty series (series with no episodes) const emptySeries = tvSeries.value.filter(series => { return getSeriesEpisodeCount(series.id) === 0; }); - // Delete empty series - for (const emptySeriesItem of emptySeries) { - console.log(`Deleting empty series: ${emptySeriesItem.name}`); - try { - await deleteStructureMutation.mutateAsync(emptySeriesItem.id); - } catch (error) { - console.error('Failed to delete empty series:', error); - } - } + return { + series: emptySeries, + seasons: emptySeasons + }; }); const artists = computed(() => { @@ -772,13 +768,37 @@ const currentArtistReleases = computed(() => { }); const allTVEpisodes = computed(() => { - if (!releases.value) return []; - return releases.value.filter((r: any) => r.categorySlug === 'tv-shows'); + if (!releases.value || !contentCategories.value) return []; + + // Get all TV show category IDs (including federated) + const tvCategoryIds = new Set(); + contentCategories.value.forEach(cat => { + if (cat.categoryId === 'tv-shows') { + tvCategoryIds.add(cat.id); + if (cat.allIds) { + cat.allIds.forEach(id => tvCategoryIds.add(id)); + } + } + }); + + return releases.value.filter((r: any) => tvCategoryIds.has(r.categoryId)); }); const allMusicReleases = computed(() => { - if (!releases.value) return []; - return releases.value.filter((r: any) => r.categorySlug === 'music'); + if (!releases.value || !contentCategories.value) return []; + + // Get all music category IDs (including federated) + const musicCategoryIds = new Set(); + contentCategories.value.forEach(cat => { + if (cat.categoryId === 'music') { + musicCategoryIds.add(cat.id); + if (cat.allIds) { + cat.allIds.forEach(id => musicCategoryIds.add(id)); + } + } + }); + + return releases.value.filter((r: any) => musicCategoryIds.has(r.categoryId)); }); // Counts @@ -855,23 +875,45 @@ const musicTableHeaders = [ // Helper functions function getSeriesEpisodeCount(seriesId: string): number { - if (!releases.value) return 0; + if (!releases.value || !contentCategories.value) return 0; + + // Get all TV show category IDs (including federated) + const tvCategoryIds = new Set(); + contentCategories.value.forEach(cat => { + if (cat.categoryId === 'tv-shows') { + tvCategoryIds.add(cat.id); + if (cat.allIds) { + cat.allIds.forEach(id => tvCategoryIds.add(id)); + } + } + }); // Count episodes that belong to this series return releases.value.filter((r: any) => - r.categorySlug === 'tv-shows' && + tvCategoryIds.has(r.categoryId) && r.metadata?.seriesId === seriesId ).length; } function getSeriesSeasonCount(seriesId: string): number { - if (!releases.value) return 0; + if (!releases.value || !contentCategories.value) return 0; + + // Get all TV show category IDs (including federated) + const tvCategoryIds = new Set(); + contentCategories.value.forEach(cat => { + if (cat.categoryId === 'tv-shows') { + tvCategoryIds.add(cat.id); + if (cat.allIds) { + cat.allIds.forEach(id => tvCategoryIds.add(id)); + } + } + }); // Get unique season numbers from episodes of this series const seasonNumbers = new Set( releases.value .filter((r: any) => - r.categorySlug === 'tv-shows' && + tvCategoryIds.has(r.categoryId) && r.metadata?.seriesId === seriesId && r.metadata?.seasonNumber !== undefined ) diff --git a/packages/renderer/src/components/misc/infiniteReleaseList.vue b/packages/renderer/src/components/misc/infiniteReleaseList.vue index 7568badb..02716b82 100644 --- a/packages/renderer/src/components/misc/infiniteReleaseList.vue +++ b/packages/renderer/src/components/misc/infiniteReleaseList.vue @@ -49,7 +49,8 @@ import { useGetReleasesQuery, useGetStructuresQuery, useContentCategoriesQuery } import type { SearchOptions } from '@riffcc/lens-sdk'; const props = defineProps<{ - categoryFilter?: string; + categoryFilter?: string; // Category ID (hash) to filter by + categorySlug?: string; // Category slug (e.g., 'music', 'tv-shows') to filter by searchOptions?: SearchOptions; pageSize?: number; }>(); @@ -63,18 +64,14 @@ const PAGE_SIZE = props.pageSize || 60; // Show many items to fill ultrawide scr const currentPage = ref(1); // Fetch releases with the configured batch size (100) -const { data: releases, isLoading } = useGetReleasesQuery({ - searchOptions: props.searchOptions, -}); +const { data: releases, isLoading } = useGetReleasesQuery(); // Get content categories to check if this is a TV category const { data: contentCategories } = useContentCategoriesQuery(); // Check if this is the TV shows category const isTVCategory = computed(() => { - if (!props.categoryFilter || !contentCategories.value) return false; - const category = contentCategories.value.find(c => c.id === props.categoryFilter); - return category?.displayName === 'TV Shows' || category?.categoryId === 'tv-shows'; + return props.categorySlug === 'tv-shows' || props.categoryFilter === 'tv-shows'; }); // Fetch structures for TV series grouping @@ -93,23 +90,31 @@ watch(structures, (newStructures) => { } }, { immediate: true }); -// Filter releases by category if needed +// Filter releases client-side if we have a category filter const filteredReleases = computed(() => { if (!releases.value) return []; let categoryReleases = releases.value; - if (props.categoryFilter && contentCategories.value) { - // Find the category to get all its IDs (from different lenses) - const category = contentCategories.value.find(c => c.id === props.categoryFilter); - if (category && category.allIds) { - // Filter by all IDs from federated categories - categoryReleases = releases.value.filter(release => - category.allIds.includes(release.categoryId) - ); - } else { - // Fallback to single ID filter - categoryReleases = releases.value.filter(release => release.categoryId === props.categoryFilter); + + // Filter by category slug if specified (for federation support) + if (props.categorySlug && contentCategories.value) { + // Find all categories with this slug + const matchingCategories = contentCategories.value.filter(c => c.categoryId === props.categorySlug); + if (matchingCategories.length > 0) { + // Get all category IDs including federated ones + const allCategoryIds = new Set(); + for (const cat of matchingCategories) { + allCategoryIds.add(cat.id); + if (cat.allIds) { + cat.allIds.forEach(id => allCategoryIds.add(id)); + } + } + // Filter releases that match any of these category IDs + categoryReleases = categoryReleases.filter(r => allCategoryIds.has(r.categoryId)); } + } else if (props.categoryFilter) { + // Direct category ID filter (for non-federated) + categoryReleases = categoryReleases.filter(r => r.categoryId === props.categoryFilter); } // If this is a TV category, group episodes by series diff --git a/packages/renderer/src/plugins/lensService/hooks.ts b/packages/renderer/src/plugins/lensService/hooks.ts index de43aa9e..0d8c5e15 100644 --- a/packages/renderer/src/plugins/lensService/hooks.ts +++ b/packages/renderer/src/plugins/lensService/hooks.ts @@ -1,4 +1,4 @@ -import { inject, type Ref } from 'vue'; +import { inject, type Ref, computed, unref } from 'vue'; import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'; import { API_URL } from '../router'; import type { diff --git a/packages/renderer/src/plugins/vuetify.ts b/packages/renderer/src/plugins/vuetify.ts index 8e89d01f..6ff0eb07 100644 --- a/packages/renderer/src/plugins/vuetify.ts +++ b/packages/renderer/src/plugins/vuetify.ts @@ -50,6 +50,10 @@ import { mdiBlockHelper, mdiGamepad, mdiCursorDefaultOutline, + mdiTelevision, + mdiMusic, + mdiTag, + mdiFolder, } from '@mdi/js'; const iconsAliasesMapping = { @@ -92,6 +96,10 @@ const iconsAliasesMapping = { 'block-helper': mdiBlockHelper, 'gamepad': mdiGamepad, 'cursor-default-outline': mdiCursorDefaultOutline, + 'television': mdiTelevision, + 'music': mdiMusic, + 'tag': mdiTag, + 'folder': mdiFolder, }; const vuetify = createVuetify({ diff --git a/packages/renderer/src/views/categoryPage.vue b/packages/renderer/src/views/categoryPage.vue index 2d909383..339842b1 100644 --- a/packages/renderer/src/views/categoryPage.vue +++ b/packages/renderer/src/views/categoryPage.vue @@ -7,7 +7,7 @@

{{ pageCategory?.displayName }}

@@ -106,11 +106,15 @@ const featuredReleasesInCategory = computed(() => { .map(fr => fr.releaseId); // Filter releases that are both featured and in this category - const categoryReleases = releases.value.filter(r => - r.id && - activeFeaturedReleaseIds.includes(r.id) && - r.categoryId === pageCategory.value?.id, - ); + // Look up each release's category to check if it matches our target slug + const targetSlug = pageCategory.value?.categoryId; // e.g., 'music', 'tv-shows' + const categoryReleases = releases.value.filter(r => { + if (!r.id || !activeFeaturedReleaseIds.includes(r.id)) return false; + + // Look up this release's category + const releaseCategory = contentCategories.value?.find(c => c.id === r.categoryId); + return releaseCategory?.categoryId === targetSlug; + }); // For TV shows, group by series and return series tiles if (isTVCategory.value) { From 7a758da723f889dfb93bc9a96062572fd10b52a7 Mon Sep 17 00:00:00 2001 From: Benjamin Arntzen Date: Mon, 4 Aug 2025 09:58:40 +0100 Subject: [PATCH 16/17] Improve TV shows structures --- .../components/admin/structuresManagement.vue | 89 ++++++++++++++++--- .../src/components/releases/releaseForm.vue | 44 ++++++--- 2 files changed, 111 insertions(+), 22 deletions(-) diff --git a/packages/renderer/src/components/admin/structuresManagement.vue b/packages/renderer/src/components/admin/structuresManagement.vue index a66116a5..b0d6b8e3 100644 --- a/packages/renderer/src/components/admin/structuresManagement.vue +++ b/packages/renderer/src/components/admin/structuresManagement.vue @@ -14,14 +14,14 @@ v-bind="activatorProps" >
- + - +

Manage Structures

@@ -182,8 +182,8 @@ +
Seasons
+