chore: remove leftovers from upstream
Drops the /flash and /html5 sections (old URLs redirect to /browse), the GitHub star count endpoint, the unused HOME_RECENT_HOURS setting, upstream's design notes and code review docs, and the issue template's link to upstream discussions.
This commit is contained in:
@@ -105,7 +105,6 @@ ENABLE_CACHE_PREWARM=true
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application
|
||||
# ---------------------------------------------------------------------------
|
||||
# HOME_RECENT_HOURS=24
|
||||
# ENABLE_CGI=false
|
||||
# Streamed play for games that can't run in a browser (Java, Shockwave, the
|
||||
# original Flash Player, Windows games). Set a token (e.g. `openssl rand -hex 32`)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Ask a Question
|
||||
url: https://github.com/darkraise/flashpoint-web/discussions
|
||||
about: Use Discussions for questions, help, and general conversation
|
||||
- name: Flashpoint Archive
|
||||
url: https://github.com/FlashpointProject
|
||||
about: For issues with Flashpoint itself (not this web interface)
|
||||
|
||||
@@ -1193,13 +1193,8 @@ Before submitting code, verify:
|
||||
## Environment
|
||||
|
||||
```bash
|
||||
FLASHPOINT_PATH=D:/Flashpoint # Required - path to Flashpoint installation
|
||||
FLASHPOINT_PATH=/path/to/flashpoint # Required - path to Flashpoint installation
|
||||
JWT_SECRET=your-secret # Required - random string for JWT signing
|
||||
DOMAIN=http://localhost:5173 # Optional - frontend URL for CORS
|
||||
LOG_LEVEL=info # Optional - debug, info, warn, error
|
||||
```
|
||||
|
||||
## Source References
|
||||
|
||||
- Flashpoint Launcher: `D:\Repositories\Community\launcher`
|
||||
- Flashpoint Game Server: `D:\Repositories\Community\FlashpointGameServer`
|
||||
|
||||
@@ -22,7 +22,6 @@ BCRYPT_SALT_ROUNDS=10
|
||||
# ENABLE_CGI=false
|
||||
|
||||
# Misc
|
||||
HOME_RECENT_HOURS=24
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database Performance Optimization (for network storage)
|
||||
|
||||
@@ -167,8 +167,6 @@ export const config = {
|
||||
|
||||
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '', 10) || 10,
|
||||
|
||||
homeRecentHours: parseInt(process.env.HOME_RECENT_HOURS || '', 10) || 24,
|
||||
|
||||
// Session manager that streams games which can't run in a browser, such as
|
||||
// Java applets (flashpoint-ultimate-server). Streaming is off unless both are set.
|
||||
streamingApiUrl: process.env.STREAMING_API_URL || null,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { logger } from '../utils/logger';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// In-memory cache for GitHub star count (avoid hitting 60/hr unauthenticated rate limit)
|
||||
let cachedStars: { count: number; fetchedAt: number } | null = null;
|
||||
const CACHE_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
// Proxy to avoid browser CORS issues with GitHub API
|
||||
router.get(
|
||||
'/stars',
|
||||
asyncHandler(async (_req: Request, res: Response) => {
|
||||
if (cachedStars && Date.now() - cachedStars.fetchedAt < CACHE_TTL_MS) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { stars: cachedStars.count },
|
||||
});
|
||||
}
|
||||
|
||||
const repoUrl = 'https://api.github.com/repos/darkraise/flashpoint-web';
|
||||
|
||||
const response = await fetch(repoUrl, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Flashpoint-Web-App',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// If rate-limited and we have a stale cache, return it
|
||||
if (cachedStars) {
|
||||
logger.debug('[GitHub] Rate limited, returning stale cache');
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { stars: cachedStars.count },
|
||||
});
|
||||
}
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { stargazers_count: number };
|
||||
|
||||
cachedStars = { count: data.stargazers_count, fetchedAt: Date.now() };
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
stars: data.stargazers_count,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -25,7 +25,6 @@ import ratingsRouter from './ratings';
|
||||
import gameSavesRouter from './game-saves';
|
||||
import discoveryRouter from './discovery';
|
||||
import ruffleRouter from './ruffle';
|
||||
import githubRouter from './github';
|
||||
import domainsRouter from './domains';
|
||||
import healthRouter from './health';
|
||||
import cacheRouter from './cache';
|
||||
@@ -40,7 +39,6 @@ export function setupRoutes(app: Express): void {
|
||||
app.use('/api/settings/auth', authSettingsRouter);
|
||||
app.use('/api/settings', systemSettingsRouter);
|
||||
app.use('/api/ruffle', ruffleRouter);
|
||||
app.use('/api/github', githubRouter);
|
||||
app.use('/api/domains', domainsRouter);
|
||||
app.use('/api/jobs', jobsRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
|
||||
@@ -80,11 +80,6 @@ router.get(
|
||||
Record<string, unknown>
|
||||
> = systemSettings.getPublicSettings();
|
||||
|
||||
if (!publicSettings.app) {
|
||||
publicSettings.app = {};
|
||||
}
|
||||
publicSettings.app.homeRecentHours = config.homeRecentHours;
|
||||
|
||||
// Inject edition/version from config (auto-detected from version.txt, not stored in DB)
|
||||
if (!publicSettings.metadata) {
|
||||
publicSettings.metadata = {};
|
||||
|
||||
@@ -51,7 +51,7 @@ const INFO = {
|
||||
updateAvailable: true,
|
||||
isUnreleasedBuild: false,
|
||||
publishedAt: '2026-08-12T07:11:45Z',
|
||||
releaseUrl: 'https://github.com/darkraise/flashpoint-web/releases/tag/v1.0.42',
|
||||
releaseUrl: 'https://git.example.com/owner/dotcom-arcade/releases/tag/v1.0.42',
|
||||
changelog: '## Changes',
|
||||
checkedAt: '2026-08-12T08:00:00.000Z',
|
||||
lastCheckFailed: false,
|
||||
|
||||
@@ -56,12 +56,12 @@ export class AppUpdateService {
|
||||
return this.buildInfo();
|
||||
}
|
||||
// A forced check still respects a 60s floor. Express rate limiting allows
|
||||
// 100 requests a minute, which is no protection for a GitHub quota of 60 an
|
||||
// hour that this shares with the star count and the Ruffle update check.
|
||||
// 100 requests a minute, which is no protection for a release API's quota
|
||||
// (GitHub allows 60 an hour, shared with the Ruffle update check).
|
||||
const maxAge = force ? FORCED_REFRESH_FLOOR_MS : CACHE_TTL_MS;
|
||||
const age = this.cached === null ? Infinity : Date.now() - this.cached.fetchedAt;
|
||||
// A failure also respects the 60s floor, even when force is true, so a
|
||||
// GitHub outage or rate limit can't be hammered every request while it
|
||||
// source's outage or rate limit can't be hammered every request while it
|
||||
// still shares the API's blocking timeout with each caller.
|
||||
const failureAge = this.lastFailedAt === null ? Infinity : Date.now() - this.lastFailedAt;
|
||||
const withinFailureFloor = failureAge < FORCED_REFRESH_FLOOR_MS;
|
||||
|
||||
@@ -56,7 +56,6 @@ services:
|
||||
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
|
||||
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-100}
|
||||
# Application
|
||||
- HOME_RECENT_HOURS=${HOME_RECENT_HOURS:-24}
|
||||
# "Latest release" API URL (GitHub or Gitea) for update checks; off when unset
|
||||
- APP_UPDATE_URL=${APP_UPDATE_URL:-}
|
||||
- ENABLE_CGI=${ENABLE_CGI:-false}
|
||||
|
||||
@@ -205,9 +205,9 @@ Routes are defined in `App.tsx` with nested routing structure:
|
||||
<AppShell>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/flash-games" element={
|
||||
<Route path="/browse" element={
|
||||
<ProtectedRoute requireAuth={false}>
|
||||
<FlashGamesView />
|
||||
<BrowseView />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
|
||||
@@ -286,16 +286,7 @@ interface SidebarProps {
|
||||
|
||||
```typescript
|
||||
const gameNavItems: NavItem[] = [
|
||||
{
|
||||
path: '/flash-games',
|
||||
iconImage: '/images/Flash.png',
|
||||
label: 'Flash Games',
|
||||
},
|
||||
{
|
||||
path: '/html5-games',
|
||||
iconImage: '/images/HTML5.png',
|
||||
label: 'HTML5 Games',
|
||||
},
|
||||
{ path: '/', icon: Home, label: 'Home' },
|
||||
{ path: '/animations', icon: Film, label: 'Animations' },
|
||||
{ path: '/browse', icon: Gamepad2, label: 'Browse' },
|
||||
];
|
||||
@@ -360,11 +351,6 @@ const isNavItemActive = (item: NavItem) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Special case: Home (/) should highlight Flash Games
|
||||
if (location.pathname === '/' && itemPath === '/flash-games') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return location.pathname === itemPath;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -6,24 +6,24 @@ Dotcom Arcade frontend.
|
||||
## Route Structure
|
||||
|
||||
```
|
||||
/ → Redirects to /flash-games
|
||||
/ → HomeView (discovery homepage)
|
||||
/login → LoginView (no auth required)
|
||||
/register → RegisterView (no auth required)
|
||||
/unauthorized → UnauthorizedView
|
||||
|
||||
# Game Views (Public/Guest Accessible)
|
||||
/flash-games → FlashGamesView
|
||||
/html5-games → HTML5GamesView
|
||||
/browse → BrowseView
|
||||
/animations → AnimationsView
|
||||
/games/:id → GameDetailView
|
||||
/browse/:id → GamePage (also /animations/:id, /games/:id)
|
||||
/flash/*, /html5/* → Redirect to the same path under /browse
|
||||
|
||||
# Protected Routes (Auth Required)
|
||||
/games/:id/play → GamePlayerView (requires "games.play" permission)
|
||||
/browse/:id/play → GamePage, game started (requires "games.play")
|
||||
/dashboard → DashboardView
|
||||
/favorites → FavoritesView
|
||||
/playlists → PlaylistsView
|
||||
/playlists/:id → PlaylistDetailView
|
||||
/playlists → UserPlaylistsView
|
||||
/playlists/:id → UserPlaylistDetailView
|
||||
/flashpoint-playlists → PlaylistsView
|
||||
/settings → SettingsView
|
||||
|
||||
# Admin Routes (Permission-Based)
|
||||
@@ -91,17 +91,6 @@ interface ProtectedRouteProps {
|
||||
|
||||
## Key Views
|
||||
|
||||
### FlashGamesView / HTML5GamesView
|
||||
|
||||
Pre-filtered game browsing views.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Platform-specific filtering
|
||||
- Grid/List view toggle
|
||||
- Pagination
|
||||
- Add to favorites/playlists
|
||||
|
||||
### BrowseView
|
||||
|
||||
Main game browsing view with full filtering.
|
||||
@@ -191,9 +180,9 @@ Routes are configured in `App.tsx`:
|
||||
<AppShell>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/flash-games" element={
|
||||
<Route path="/browse" element={
|
||||
<ProtectedRoute requireAuth={false}>
|
||||
<FlashGamesView />
|
||||
<BrowseView />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
|
||||
@@ -202,7 +202,6 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`,
|
||||
- [GET /api/metrics/summary](./admin-api.md#get-metrics-summary)
|
||||
- [GET /api/metrics/endpoints](./admin-api.md#get-endpoint-metrics)
|
||||
- [GET /api/jobs](./admin-api.md#list-all-jobs)
|
||||
- [GET /api/github/stars](./admin-api.md#get-github-star-count)
|
||||
- [GET /api/settings/auth](./admin-api.md#get-auth-settings)
|
||||
- [PATCH /api/settings/auth](./admin-api.md#update-auth-settings)
|
||||
- [GET /game-files/\*](./admin-api.md#get-game-file)
|
||||
|
||||
@@ -213,19 +213,6 @@ Returns `202 Accepted`
|
||||
|
||||
Returns progress percentage, stage, estimated completion time.
|
||||
|
||||
## GitHub Endpoints
|
||||
|
||||
### Get GitHub Star Count
|
||||
|
||||
`GET /api/github/stars` - No auth required
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { "stars": 2847 }
|
||||
}
|
||||
```
|
||||
|
||||
## Statistics Endpoints
|
||||
|
||||
### Get Archive Statistics
|
||||
|
||||
@@ -103,7 +103,6 @@ Two notes that save debugging time:
|
||||
| Variable | Default | Description |
|
||||
| -------------------- | ------- | ------------------------------------------------ |
|
||||
| `ENABLE_CGI` | false | Enable PHP CGI execution for legacy game content |
|
||||
| `HOME_RECENT_HOURS` | 24 | Hours to look back for "recently added" games |
|
||||
|
||||
**Frontend Serving:**
|
||||
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
# Games Filter Panel & Filter Logic - Code Review
|
||||
|
||||
**Date:** 2026-02-15
|
||||
**Reviewer:** Claude
|
||||
**Status:** Review Complete
|
||||
|
||||
## Overview
|
||||
|
||||
The filter system spans frontend components, hooks, API layer, and backend services. Overall, the implementation is solid with good patterns like config-driven rendering and proper caching. However, there are several issues and improvement opportunities.
|
||||
|
||||
## Files Reviewed
|
||||
|
||||
### Frontend Components
|
||||
- `frontend/src/components/search/FilterPanel.tsx`
|
||||
- `frontend/src/components/search/FilterDropdown.tsx`
|
||||
- `frontend/src/components/search/FilterChips.tsx`
|
||||
- `frontend/src/components/search/filterConfig.ts`
|
||||
- `frontend/src/components/search/SearchBar.tsx`
|
||||
- `frontend/src/components/search/SortControls.tsx`
|
||||
- `frontend/src/components/library/GameBrowseLayout.tsx`
|
||||
|
||||
### Frontend Hooks & Types
|
||||
- `frontend/src/hooks/useGames.ts`
|
||||
- `frontend/src/hooks/useFilterOptions.ts`
|
||||
- `frontend/src/hooks/useFilterDropdowns.ts`
|
||||
- `frontend/src/types/game.ts`
|
||||
- `frontend/src/lib/api/games.ts`
|
||||
|
||||
### Backend
|
||||
- `backend/src/routes/games.ts`
|
||||
- `backend/src/services/GameService.ts`
|
||||
|
||||
---
|
||||
|
||||
## Issues Found
|
||||
|
||||
### 1. Type Safety Issues
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `FilterPanel.tsx:22-23` | Unsafe `split()` on potentially undefined value | Medium |
|
||||
| `GameBrowseLayout.tsx:72` | Type assertion `as 'asc' | 'desc'` without validation | Medium |
|
||||
| `FilterDropdown.tsx:42` | No validation that `selectedValues` array items exist in `options` | Low |
|
||||
|
||||
```typescript
|
||||
// FilterPanel.tsx:22-23 - currentValue could be number/undefined for yearFrom/yearTo
|
||||
const current = typeof currentValue === 'string' ? currentValue.split(',').filter(Boolean) : [];
|
||||
// This works but paramKey includes non-string fields (yearFrom, yearTo, page, limit)
|
||||
```
|
||||
|
||||
### 2. Missing Year Range Filter in UI
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `filterConfig.ts` | `yearFrom`/`yearTo` filters defined in `GameFilters` but not exposed in UI | Medium |
|
||||
| `FilterPanel.tsx` | No year range slider or inputs despite backend support | Medium |
|
||||
|
||||
The `FilterOptions.yearRange` is fetched but never used - users cannot filter by release year.
|
||||
|
||||
### 3. Inconsistent Filter Chips Implementation
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `FilterPanel.tsx:105-140` | `renderFilterBadges()` duplicates logic already in `FilterChips.tsx` | Low |
|
||||
| `GameBrowseLayout.tsx:110-198` | Separate filter chip generation vs `FilterChips` component | Low |
|
||||
|
||||
Two different chip rendering systems exist:
|
||||
- `FilterPanel.tsx` renders inline badges
|
||||
- `GameBrowseLayout.tsx` creates `FilterChip[]` array for `FilterChips` component
|
||||
|
||||
### 4. Performance: Redundant Handlers Recreation
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `FilterPanel.tsx:68-74` | `createFilterHandlers()` called on every render for every filter | Low |
|
||||
| `FilterPanel.tsx:113-119` | Same handlers recreated in `renderFilterBadges()` | Low |
|
||||
|
||||
```typescript
|
||||
// Called inside map() on every render
|
||||
const handlers = createFilterHandlers(
|
||||
config.paramKey,
|
||||
config.id,
|
||||
filters,
|
||||
updateFilter,
|
||||
dropdowns
|
||||
);
|
||||
```
|
||||
|
||||
### 5. Missing Search Debouncing in SearchBar
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `SearchBar.tsx:10-15` | No debounce - navigates immediately on form submit | Low |
|
||||
| N/A | No "search as you type" with debounce option | Enhancement |
|
||||
|
||||
### 6. Backend: Filter Options Limit Inconsistency
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `GameService.ts:706-707` | Series limited to 100 | Low |
|
||||
| `GameService.ts:730-731` | Developers limited to 100 | Low |
|
||||
| `GameService.ts:780-781` | Play modes NOT limited | Inconsistent |
|
||||
| `GameService.ts:849` | Tags limited to 100 | Low |
|
||||
|
||||
Play modes query lacks `LIMIT 100` that other filter queries have.
|
||||
|
||||
### 7. Missing Input Validation on Frontend
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `GameBrowseLayout.tsx:72` | `page` parsed without `isNaN()` check | Medium |
|
||||
| `GameBrowseLayout.tsx:58-68` | Year parsing validates `isNaN()` - good! | N/A |
|
||||
|
||||
```typescript
|
||||
// Line 72 - could pass NaN to API
|
||||
page: parseInt(searchParams.get('page') || '1', 10),
|
||||
```
|
||||
|
||||
### 8. URL Encoding Issues
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `GameBrowseLayout.tsx:134` | `encodeURIComponent()` used for chip IDs but inconsistent | Low |
|
||||
| `FilterPanel.tsx:125` | Badge key uses raw `value` - may have special chars | Low |
|
||||
|
||||
```typescript
|
||||
// Line 125 - key should be encoded for consistency
|
||||
<Badge key={value} variant="secondary" className="gap-1">
|
||||
```
|
||||
|
||||
### 9. Missing Error Boundary for Filter Options
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `useFilterOptions.ts` | No `onError` handler | Low |
|
||||
| `FilterPanel.tsx:79` | Falls back to empty array `|| []` but no loading state | Low |
|
||||
|
||||
If filter options fail to load, dropdowns show "No options available" with no retry mechanism.
|
||||
|
||||
### 10. Accessibility Issues
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `SortControls.tsx:26` | Native `<select>` used without styling wrapper | Low |
|
||||
| `FilterDropdown.tsx:103` | `<label>` wraps checkbox - good | N/A |
|
||||
| `SortControls.tsx:35-45` | Sort order button shows "A-Z/Z-A" but not meaningful for dates | Medium |
|
||||
|
||||
```typescript
|
||||
// "A-Z" / "Z-A" makes sense for title, not for dateAdded
|
||||
<span className="ml-1 text-xs">{sortOrder === 'asc' ? 'A-Z' : 'Z-A'}</span>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Improvements Recommended
|
||||
|
||||
### High Priority
|
||||
|
||||
1. **Add Year Range Filter UI**
|
||||
- Add a dual-handle range slider component
|
||||
- Use `yearRange` from `FilterOptions`
|
||||
- Connect to `yearFrom`/`yearTo` URL params
|
||||
|
||||
2. **Validate Page Parameter**
|
||||
```typescript
|
||||
// GameBrowseLayout.tsx:72
|
||||
page: Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1),
|
||||
```
|
||||
|
||||
3. **Fix Sort Order Label for Dates**
|
||||
```typescript
|
||||
const sortOrderLabel = useMemo(() => {
|
||||
if (['releaseDate', 'dateAdded', 'dateModified'].includes(sortBy)) {
|
||||
return sortOrder === 'asc' ? 'Oldest' : 'Newest';
|
||||
}
|
||||
return sortOrder === 'asc' ? 'A-Z' : 'Z-A';
|
||||
}, [sortBy, sortOrder]);
|
||||
```
|
||||
|
||||
### Medium Priority
|
||||
|
||||
4. **Consolidate Filter Chip Rendering**
|
||||
- Remove inline badge rendering from `FilterPanel.tsx`
|
||||
- Use `FilterChips` component consistently
|
||||
- Pass chip generation logic via props or shared utility
|
||||
|
||||
5. **Add Filter Options Loading State**
|
||||
```typescript
|
||||
const { data: filterOptions, isLoading: filterOptionsLoading, error } = useFilterOptions();
|
||||
|
||||
// Show skeleton or disabled state while loading
|
||||
```
|
||||
|
||||
6. **Memoize Filter Handlers**
|
||||
```typescript
|
||||
const handlers = useMemo(() =>
|
||||
FILTER_CONFIGS.reduce((acc, config) => ({
|
||||
...acc,
|
||||
[config.id]: createFilterHandlers(config.paramKey, config.id, filters, updateFilter, dropdowns)
|
||||
}), {}),
|
||||
[filters, updateFilter, dropdowns]
|
||||
);
|
||||
```
|
||||
|
||||
7. **Add Search-as-You-Type with Debounce**
|
||||
```typescript
|
||||
// In a new SearchInput component
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const debouncedSearch = useDebounce(inputValue, 300);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearch) {
|
||||
navigate(`/browse?search=${encodeURIComponent(debouncedSearch)}`);
|
||||
}
|
||||
}, [debouncedSearch]);
|
||||
```
|
||||
|
||||
### Low Priority
|
||||
|
||||
8. **Consistent LIMIT on All Filter Queries**
|
||||
```sql
|
||||
-- GameService.ts:780-781
|
||||
ORDER BY count DESC, playMode ASC
|
||||
LIMIT 100 -- Add this
|
||||
```
|
||||
|
||||
9. **Add Filter Presets**
|
||||
- Save/load common filter combinations
|
||||
- "Popular Flash Games", "Recent Additions", etc.
|
||||
|
||||
10. **Add Filter Count in Dropdown Trigger**
|
||||
- Show total matching games for current filter combination
|
||||
- Update counts dynamically based on other selected filters (cascading filters)
|
||||
|
||||
11. **Keyboard Navigation Improvements**
|
||||
- Arrow keys to navigate dropdown options
|
||||
- Enter to toggle selection
|
||||
- Escape to close dropdown
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Observations
|
||||
|
||||
### Positive Patterns
|
||||
|
||||
| Pattern | Location | Notes |
|
||||
|---------|----------|-------|
|
||||
| Config-driven filter rendering | `filterConfig.ts` | Eliminates 7 duplicate filter definitions |
|
||||
| Proper `asyncHandler()` usage | `games.ts` routes | All async routes wrapped |
|
||||
| Zod validation | `games.ts:27-49` | Comprehensive query validation |
|
||||
| Window function for count | `GameService.ts:247` | Efficient single-query pagination |
|
||||
| Cache pre-warming | `GameService.ts:158-166` | Filter options cached at startup |
|
||||
| Proper URL param handling | `GameBrowseLayout.tsx` | `searchParams` synced correctly |
|
||||
|
||||
### Areas for Improvement
|
||||
|
||||
| Area | Current | Recommended |
|
||||
|------|---------|-------------|
|
||||
| Handler memoization | Created inline per render | Use `useMemo` or move outside component |
|
||||
| Error handling | Silent fallback to `[]` | Show error state with retry |
|
||||
| Year range UI | Missing | Add range slider component |
|
||||
| Filter persistence | URL only | Consider localStorage for user defaults |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Issues | Improvements |
|
||||
|----------|--------|--------------|
|
||||
| Type Safety | 3 | Validate all URL params |
|
||||
| Missing Features | 2 | Year filter, presets |
|
||||
| Performance | 2 | Memoize handlers |
|
||||
| Consistency | 3 | Unify chip rendering |
|
||||
| Accessibility | 2 | Sort labels, keyboard nav |
|
||||
| Backend | 1 | Consistent LIMIT |
|
||||
| **Total** | **13** | **10 recommendations** |
|
||||
|
||||
The filter system is functional and well-architected with the config-driven approach. The main gaps are the missing year range filter UI, sort order labels that don't match the sort type, and some performance optimizations around handler memoization.
|
||||
|
||||
---
|
||||
|
||||
## Action Items
|
||||
|
||||
- [x] Add year range filter slider to FilterPanel *(Fixed: 2026-02-15)*
|
||||
- [x] Fix page parameter validation in GameBrowseLayout *(Fixed: 2026-02-15)*
|
||||
- [x] Update SortControls to show context-aware labels (Oldest/Newest vs A-Z/Z-A) *(Fixed: 2026-02-15)*
|
||||
- [x] Add LIMIT 100 to play modes query in GameService *(Fixed: 2026-02-15)*
|
||||
- [x] Consolidate filter chip rendering to use FilterChips component only *(Fixed: 2026-02-15)*
|
||||
- [x] Add loading/error states for filter options *(Fixed: 2026-02-15)*
|
||||
- [x] Memoize filter handlers in FilterPanel *(Fixed: 2026-02-15)*
|
||||
|
||||
## Fixes Applied (2026-02-15)
|
||||
|
||||
### Files Modified
|
||||
|
||||
1. **`frontend/src/components/library/GameBrowseLayout.tsx`**
|
||||
- Added `isNaN()` validation for page parameter with `Math.max(1, ...)` fallback
|
||||
- Added year range chip generation and removal handling
|
||||
|
||||
2. **`frontend/src/components/search/SortControls.tsx`**
|
||||
- Added context-aware sort order labels: "Oldest/Newest" for date fields, "A-Z/Z-A" for text fields
|
||||
- Improved accessibility with `aria-label`
|
||||
|
||||
3. **`frontend/src/components/search/FilterPanel.tsx`**
|
||||
- Memoized filter handlers using `useMemo` to avoid recreation on every render
|
||||
- Memoized `updateFilter` with `useCallback`
|
||||
- Memoized `visibleConfigs` array
|
||||
- Removed duplicate badge rendering (now uses `FilterChips` only)
|
||||
- Added loading state for filter options ("Loading..." message)
|
||||
- Integrated `YearRangeFilter` component
|
||||
|
||||
4. **`frontend/src/components/search/YearRangeFilter.tsx`** (new file)
|
||||
- Created year range filter component with from/to inputs
|
||||
- Supports compact mode for mobile
|
||||
- Proper validation of year values
|
||||
|
||||
5. **`backend/src/services/GameService.ts`**
|
||||
- Added `LIMIT 100` to play modes query for consistency with other filter queries
|
||||
|
||||
## Additional Improvements (2026-02-15)
|
||||
|
||||
### Error State with Retry for Filter Options
|
||||
|
||||
**`frontend/src/components/search/FilterPanel.tsx`** - Added:
|
||||
- Error state detection from `useFilterOptions` hook
|
||||
- User-friendly error message with alert styling
|
||||
- Retry button to refetch filter options
|
||||
- Graceful fallback - shows filters if data exists even with stale error
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,798 +0,0 @@
|
||||
# Persistent Ruffle Installation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
> **Implementer assignments:** each task names its implementer agent in an
|
||||
> `**Implementer:**` line. When executing with
|
||||
> superpowers:subagent-driven-development, REQUIRED SUB-SKILL:
|
||||
> dcc-superpower-companions:dispatching-tiered-implementers. Under
|
||||
> superpowers:executing-plans these lines are inert; ignore them.
|
||||
|
||||
**Goal:** A Ruffle version installed through Settings → Update survives container recreation.
|
||||
|
||||
**Architecture:** Ruffle installs into `/app/data/ruffle`, which sits on the already-mounted data volume, instead of `/app/frontend/dist/ruffle`, which lives in the container's throwaway writable layer. A fresh volume is seeded from the copy baked into the image, so Flash works immediately and offline. Express serves the persisted directory at `/ruffle`, ahead of the dist handler and with cache headers that let an updated loader actually reach browsers.
|
||||
|
||||
**Tech Stack:** Express + TypeScript, vitest + supertest, Docker Compose.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-13-ruffle-persistent-install-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Backend tests run from the `backend/` directory with `npx vitest run`. Do **not** use `npm test` from the repository root — it enters watch mode and hangs.
|
||||
- Never introduce `any` types or non-null assertions (`!`). Use `??` for defaults, never `||`.
|
||||
- Use `logger` from `../utils/logger`, never `console`.
|
||||
- Run `npx prettier --write <files>` on every changed file before committing.
|
||||
- The environment variable name is exactly `RUFFLE_DATA_PATH`. The production default is exactly `/app/data/ruffle`.
|
||||
- `config.ruffleDataPath` is the install directory itself, not its parent. `path.join(config.ruffleDataPath, 'ruffle')` is always wrong.
|
||||
- Ruffle's content-hashed files are detected by a hex run of **16 or more** characters in the basename (`core.ruffle.4ca82b563e9711217164.js`, `f2a570ccf4468b20d95a.wasm`). Everything else — `ruffle.js`, `ruffle.js.map`, `package.json` — is stable-named and must revalidate.
|
||||
- Do not delete `frontend/public/ruffle/`. It is the seed source, and Vite serves it in development.
|
||||
- Do not add a release-note entry to `docs/09-deployment/upgrade-guide.md`. That file gains a version heading only when a release is cut, and inventing a version number here would be wrong.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Persisted install path
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `backend/src/config.ts:118-119`
|
||||
- Modify: `backend/src/services/RuffleService.ts:41-52`
|
||||
- Test: `backend/src/services/RuffleService.test.ts:7,78-85,124-126,138-157`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: nothing.
|
||||
- Produces: `config.ruffleDataPath: string` — the absolute directory Ruffle is installed into. `RuffleService` gains a private `installPath` (the install target) and a private `bundledPath` (the image's baked copy at `<frontendDistPath>/ruffle`), both read by Task 2.
|
||||
|
||||
**Implementer:** dcc-superpower-companions:impl-sonnet-max
|
||||
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 2 = 5
|
||||
|
||||
- [ ] **Step 1: Add the config value**
|
||||
|
||||
In `backend/src/config.ts`, find this block:
|
||||
|
||||
```typescript
|
||||
// __dirname is backend/dist at runtime, so this resolves to the frontend build
|
||||
// copied next to the backend in the image (/app/frontend/dist).
|
||||
frontendDistPath:
|
||||
process.env.FRONTEND_DIST_PATH ?? path.resolve(__dirname, '../../frontend/dist'),
|
||||
```
|
||||
|
||||
Insert directly after it:
|
||||
|
||||
```typescript
|
||||
// Ruffle installs here rather than under the served frontend build. That build
|
||||
// is part of the image, so it lives in the container's writable layer and an
|
||||
// in-app update was discarded whenever the container was recreated.
|
||||
ruffleDataPath:
|
||||
process.env.RUFFLE_DATA_PATH ??
|
||||
(process.env.NODE_ENV === 'production'
|
||||
? '/app/data/ruffle'
|
||||
: path.resolve(__dirname, '../data/ruffle')),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the test fixtures to a separate data directory**
|
||||
|
||||
In `backend/src/services/RuffleService.test.ts`, change the declaration on line 7:
|
||||
|
||||
```typescript
|
||||
let distDir: string;
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
let distDir: string;
|
||||
let dataDir: string;
|
||||
```
|
||||
|
||||
Replace the config mock (lines 78-85):
|
||||
|
||||
```typescript
|
||||
vi.mock('../config', () => ({
|
||||
config: {
|
||||
serveFrontend: true,
|
||||
get frontendDistPath() {
|
||||
return distDir;
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
vi.mock('../config', () => ({
|
||||
config: {
|
||||
serveFrontend: true,
|
||||
get frontendDistPath() {
|
||||
return distDir;
|
||||
},
|
||||
get ruffleDataPath() {
|
||||
return nodePath.join(dataDir, 'ruffle');
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
Replace the `ruffleDir` helper (lines 124-126):
|
||||
|
||||
```typescript
|
||||
function ruffleDir(): string {
|
||||
return nodePath.join(distDir, 'ruffle');
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
function ruffleDir(): string {
|
||||
return nodePath.join(dataDir, 'ruffle');
|
||||
}
|
||||
```
|
||||
|
||||
In `beforeEach`, replace the single `distDir` assignment:
|
||||
|
||||
```typescript
|
||||
distDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-'));
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
distDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-dist-'));
|
||||
dataDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-data-'));
|
||||
```
|
||||
|
||||
In `afterEach`, replace:
|
||||
|
||||
```typescript
|
||||
nodeFs.rmSync(distDir, { recursive: true, force: true });
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
nodeFs.rmSync(distDir, { recursive: true, force: true });
|
||||
nodeFs.rmSync(dataDir, { recursive: true, force: true });
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the tests to verify they fail**
|
||||
|
||||
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
|
||||
|
||||
Expected: FAIL. The suite now asserts against `dataDir/ruffle` while the service still installs into `distDir/ruffle`, so assertions such as `expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('new build')` throw ENOENT.
|
||||
|
||||
- [ ] **Step 4: Point the service at the persisted path**
|
||||
|
||||
In `backend/src/services/RuffleService.ts`, replace the field declaration and constructor (lines 41-52):
|
||||
|
||||
```typescript
|
||||
private readonly frontendPublicPath: string;
|
||||
private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';
|
||||
|
||||
constructor() {
|
||||
// Install into the directory that is actually served. In a built deployment
|
||||
// that is the frontend's dist output; in development Vite serves public/.
|
||||
// Pointing at public/ in a container made every fresh start re-download
|
||||
// Ruffle even though the image already ships it in dist/.
|
||||
this.frontendPublicPath = config.serveFrontend
|
||||
? path.join(config.frontendDistPath, 'ruffle')
|
||||
: path.resolve(__dirname, '../../../frontend/public/ruffle');
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
private readonly installPath: string;
|
||||
private readonly bundledPath: string;
|
||||
private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';
|
||||
|
||||
constructor() {
|
||||
// A built deployment installs onto the mounted data volume and serves it
|
||||
// from there: the frontend build is part of the image, so anything written
|
||||
// into it is discarded when the container is recreated. In development Vite
|
||||
// serves public/, which is the only directory reachable there.
|
||||
this.installPath = config.serveFrontend
|
||||
? config.ruffleDataPath
|
||||
: path.resolve(__dirname, '../../../frontend/public/ruffle');
|
||||
this.bundledPath = path.join(config.frontendDistPath, 'ruffle');
|
||||
}
|
||||
```
|
||||
|
||||
Then rename the remaining nine references. Every other occurrence of `this.frontendPublicPath` in the file becomes `this.installPath` — lines 81, 447, 495, 520, 526, 527, 532, 546, and 576. A whole-word replace of `frontendPublicPath` with `installPath` across the file is exact; there are no other matches.
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
|
||||
|
||||
Expected: PASS, all existing cases green. They now exercise install, backup, rollback, and the rename/copy fallbacks against the persisted directory.
|
||||
|
||||
- [ ] **Step 6: Typecheck**
|
||||
|
||||
Run from the repository root: `npm run typecheck`
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
npx prettier --write backend/src/config.ts backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts
|
||||
git add backend/src/config.ts backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts
|
||||
git commit -m "fix(ruffle): install onto the persisted data volume"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Seed a fresh volume from the bundled copy
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `backend/src/services/RuffleService.ts` (add two methods after `verifyInstallation`)
|
||||
- Modify: `backend/src/server.ts:296-314`
|
||||
- Test: `backend/src/services/RuffleService.test.ts` (append a new `describe` block)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `this.installPath` and `this.bundledPath` from Task 1; the existing private `copyDirectory(source: string, target: string): void`.
|
||||
- Produces: `RuffleService.ensureInstalled(): Promise<'present' | 'seeded' | 'downloaded'>`, called by `backend/src/server.ts`.
|
||||
|
||||
**Implementer:** dcc-superpower-companions:impl-sonnet-xhigh
|
||||
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 1 = 4
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `backend/src/services/RuffleService.test.ts`:
|
||||
|
||||
```typescript
|
||||
function installBundledRuffle(): void {
|
||||
const bundled = nodePath.join(distDir, 'ruffle');
|
||||
nodeFs.mkdirSync(bundled, { recursive: true });
|
||||
nodeFs.writeFileSync(nodePath.join(bundled, 'ruffle.js'), 'bundled build');
|
||||
nodeFs.writeFileSync(
|
||||
nodePath.join(bundled, 'package.json'),
|
||||
JSON.stringify({ version: '0.2.0-nightly.2026.1.29' })
|
||||
);
|
||||
}
|
||||
|
||||
describe('RuffleService.ensureInstalled', () => {
|
||||
it('leaves an existing installation alone', async () => {
|
||||
installExistingRuffle();
|
||||
installBundledRuffle();
|
||||
|
||||
const result = await new RuffleService().ensureInstalled();
|
||||
|
||||
expect(result).toBe('present');
|
||||
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('old build');
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds from the bundled copy when nothing is installed', async () => {
|
||||
installBundledRuffle();
|
||||
const service = new RuffleService();
|
||||
|
||||
const result = await service.ensureInstalled();
|
||||
|
||||
expect(result).toBe('seeded');
|
||||
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe(
|
||||
'bundled build'
|
||||
);
|
||||
expect(service.getCurrentVersion()).toBe('0.2.0-nightly.2026.1.29');
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads when neither an installation nor a bundled copy exists', async () => {
|
||||
const result = await new RuffleService().ensureInstalled();
|
||||
|
||||
expect(result).toBe('downloaded');
|
||||
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('new build');
|
||||
expect(axios.get).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts -t ensureInstalled`
|
||||
|
||||
Expected: FAIL with `ensureInstalled is not a function`.
|
||||
|
||||
- [ ] **Step 3: Implement the methods**
|
||||
|
||||
In `backend/src/services/RuffleService.ts`, add both methods directly after `verifyInstallation()`, inside the class:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Copy the Ruffle shipped inside the image into the install directory. A fresh
|
||||
* data volume otherwise has no player at all until a download finishes — and
|
||||
* never gets one on a host that cannot reach GitHub.
|
||||
*/
|
||||
private seedFromBundle(): boolean {
|
||||
if (this.bundledPath === this.installPath) {
|
||||
return false;
|
||||
}
|
||||
if (!fs.existsSync(path.join(this.bundledPath, 'ruffle.js'))) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
this.copyDirectory(this.bundledPath, this.installPath);
|
||||
logger.info(
|
||||
`✅ Ruffle seeded from the bundled copy (version: ${this.getCurrentVersion() ?? 'unknown'})`
|
||||
);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
logger.warn('[RuffleService] Could not seed Ruffle from the bundled copy:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a Ruffle available, preferring what is already installed, then the copy
|
||||
* baked into the image, and only then a download.
|
||||
*/
|
||||
async ensureInstalled(): Promise<'present' | 'seeded' | 'downloaded'> {
|
||||
if (this.verifyInstallation()) {
|
||||
logger.info(`✅ Ruffle verified (version: ${this.getCurrentVersion() ?? 'unknown'})`);
|
||||
return 'present';
|
||||
}
|
||||
|
||||
if (this.seedFromBundle()) {
|
||||
return 'seeded';
|
||||
}
|
||||
|
||||
logger.info('🎮 Ruffle not found, downloading latest version...');
|
||||
await this.updateRuffle();
|
||||
logger.info('✅ Ruffle installation complete');
|
||||
return 'downloaded';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
|
||||
|
||||
Expected: PASS, including the three new cases and every pre-existing one.
|
||||
|
||||
- [ ] **Step 5: Call it from startup**
|
||||
|
||||
In `backend/src/server.ts`, replace lines 296-314:
|
||||
|
||||
```typescript
|
||||
// Ruffle installs in the background: it downloads from GitHub, and awaiting it
|
||||
// here kept the port closed for minutes on slow storage — indefinitely if the
|
||||
// download stalled. Flash games need it, everything else does not.
|
||||
void (async () => {
|
||||
try {
|
||||
const ruffleService = new RuffleService();
|
||||
if (!ruffleService.verifyInstallation()) {
|
||||
logger.info('🎮 Ruffle not found, downloading latest version...');
|
||||
await ruffleService.updateRuffle();
|
||||
logger.info('✅ Ruffle installation complete');
|
||||
} else {
|
||||
const version = ruffleService.getCurrentVersion();
|
||||
logger.info(`✅ Ruffle verified (version: ${version || 'unknown'})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to install Ruffle:', error);
|
||||
logger.warn('⚠️ Continuing without Ruffle - Flash games will not work');
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
// Ruffle resolves in the background: seeding copies ~20 MB and the download
|
||||
// fallback reaches GitHub, and awaiting either here kept the port closed for
|
||||
// minutes on slow storage. Flash games need it, everything else does not.
|
||||
void (async () => {
|
||||
try {
|
||||
await new RuffleService().ensureInstalled();
|
||||
} catch (error) {
|
||||
logger.error('Failed to install Ruffle:', error);
|
||||
logger.warn('⚠️ Continuing without Ruffle - Flash games will not work');
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Typecheck and run the full backend suite**
|
||||
|
||||
Run from the repository root: `npm run typecheck`
|
||||
|
||||
Then run from `backend/`: `npx vitest run`
|
||||
|
||||
Expected: no type errors, no new test failures.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
npx prettier --write backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts backend/src/server.ts
|
||||
git add backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts backend/src/server.ts
|
||||
git commit -m "feat(ruffle): seed a fresh data volume from the bundled copy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Serve the persisted directory with correct cache headers
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `backend/src/middleware/frontendServing.ts:20-55`
|
||||
- Modify: `backend/src/middleware/startupOrdering.test.ts:12-17`
|
||||
- Create: `backend/src/middleware/ruffleServing.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `config.ruffleDataPath` from Task 1.
|
||||
- Produces: nothing consumed by later tasks. `registerFrontendServing(app, distPath?)` keeps its existing signature; the Ruffle mount reads `config.ruffleDataPath` directly.
|
||||
|
||||
**Implementer:** dcc-superpower-companions:impl-sonnet-max
|
||||
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 2 = 5
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `backend/src/middleware/ruffleServing.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import express, { Express } from 'express';
|
||||
import request from 'supertest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
vi.mock('../utils/logger', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
let ruffleDir: string;
|
||||
|
||||
vi.mock('../config', () => ({
|
||||
config: {
|
||||
serveFrontend: true,
|
||||
get ruffleDataPath() {
|
||||
return ruffleDir;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { registerFrontendServing } from './frontendServing';
|
||||
|
||||
const HASHED_CHUNK = 'core.ruffle.4ca82b563e9711217164.js';
|
||||
|
||||
let distDir: string;
|
||||
let app: Express;
|
||||
|
||||
beforeEach(() => {
|
||||
distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-serve-dist-'));
|
||||
ruffleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-serve-ruffle-'));
|
||||
|
||||
fs.writeFileSync(path.join(distDir, 'index.html'), '<!doctype html><div id="root"></div>');
|
||||
fs.mkdirSync(path.join(distDir, 'ruffle'));
|
||||
fs.writeFileSync(path.join(distDir, 'ruffle', 'ruffle.js'), 'bundled loader');
|
||||
fs.writeFileSync(path.join(distDir, 'ruffle', 'only-in-bundle.js'), 'bundled extra');
|
||||
|
||||
fs.writeFileSync(path.join(ruffleDir, 'ruffle.js'), 'updated loader');
|
||||
fs.writeFileSync(path.join(ruffleDir, HASHED_CHUNK), 'updated chunk');
|
||||
|
||||
app = express();
|
||||
registerFrontendServing(app, distDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(distDir, { recursive: true, force: true });
|
||||
fs.rmSync(ruffleDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Ruffle serving', () => {
|
||||
it('serves the persisted copy in preference to the bundled one', async () => {
|
||||
const response = await request(app).get('/ruffle/ruffle.js');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('updated loader');
|
||||
});
|
||||
|
||||
it('falls back to the bundled copy for a file the persisted copy lacks', async () => {
|
||||
const response = await request(app).get('/ruffle/only-in-bundle.js');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('bundled extra');
|
||||
});
|
||||
|
||||
it('makes the stable-named loader revalidate', async () => {
|
||||
const response = await request(app).get('/ruffle/ruffle.js');
|
||||
|
||||
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
|
||||
});
|
||||
|
||||
it('makes a content-hashed chunk immutable', async () => {
|
||||
const response = await request(app).get(`/ruffle/${HASHED_CHUNK}`);
|
||||
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable');
|
||||
});
|
||||
|
||||
it('makes the bundled fallback loader revalidate too', async () => {
|
||||
const response = await request(app).get('/ruffle/only-in-bundle.js');
|
||||
|
||||
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run from `backend/`: `npx vitest run src/middleware/ruffleServing.test.ts`
|
||||
|
||||
Expected: FAIL. `/ruffle/ruffle.js` returns `bundled loader` because no persisted mount exists, and its `Cache-Control` is `public, max-age=31536000, immutable`.
|
||||
|
||||
- [ ] **Step 3: Add the mount and the cache policy**
|
||||
|
||||
In `backend/src/middleware/frontendServing.ts`, add this import alongside the existing ones:
|
||||
|
||||
```typescript
|
||||
import type { Response } from 'express';
|
||||
```
|
||||
|
||||
Add these two declarations directly above `export function registerFrontendServing`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Ruffle content-hashes its chunks (`core.ruffle.4ca82b563e9711217164.js`,
|
||||
* `f2a570ccf4468b20d95a.wasm`) but not its entry points. Detecting the hash
|
||||
* rather than listing the stable names keeps this correct when a release adds a
|
||||
* file.
|
||||
*/
|
||||
const HASHED_RUFFLE_ASSET = /[0-9a-f]{16,}/i;
|
||||
|
||||
/**
|
||||
* `ruffle.js` keeps its name across versions, so serving it immutable pinned
|
||||
* every returning browser to the loader it first saw — an update reached the
|
||||
* disk and nothing else.
|
||||
*/
|
||||
function setRuffleCacheControl(res: Response, filePath: string): void {
|
||||
res.setHeader(
|
||||
'Cache-Control',
|
||||
HASHED_RUFFLE_ASSET.test(path.basename(filePath))
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache, no-store, must-revalidate'
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Inside `registerFrontendServing`, insert this immediately after the `index.html` existence guard and before the existing `app.use(express.static(distPath, ...))` call:
|
||||
|
||||
```typescript
|
||||
// Ahead of the dist handler so an updated Ruffle shadows the one baked into
|
||||
// the image. `fallthrough` lets the baked copy answer for anything the
|
||||
// persisted directory is missing, including before the first seed finishes.
|
||||
app.use(
|
||||
'/ruffle',
|
||||
express.static(config.ruffleDataPath, {
|
||||
index: false,
|
||||
fallthrough: true,
|
||||
setHeaders: setRuffleCacheControl,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
Then replace the `setHeaders` option on the existing dist handler:
|
||||
|
||||
```typescript
|
||||
setHeaders: (res, filePath) => {
|
||||
// Hashed assets are immutable; index.html must always be revalidated.
|
||||
if (filePath.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
setHeaders: (res, filePath) => {
|
||||
// Hashed assets are immutable; index.html must always be revalidated.
|
||||
if (filePath.endsWith('index.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
return;
|
||||
}
|
||||
if (filePath.startsWith(bundledRufflePath + path.sep)) {
|
||||
setRuffleCacheControl(res, filePath);
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
and declare `bundledRufflePath` at the top of the function body, directly below the existing `const indexHtml = path.join(distPath, 'index.html');`:
|
||||
|
||||
```typescript
|
||||
const bundledRufflePath = path.join(distPath, 'ruffle');
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run from `backend/`: `npx vitest run src/middleware/ruffleServing.test.ts`
|
||||
|
||||
Expected: PASS, all five cases.
|
||||
|
||||
- [ ] **Step 5: Repair the sibling middleware test's config mock**
|
||||
|
||||
`backend/src/middleware/startupOrdering.test.ts` mocks `../config` without `ruffleDataPath`, and `express.static(undefined)` throws `TypeError: root path required`. Replace its mock (lines 12-17):
|
||||
|
||||
```typescript
|
||||
vi.mock('../config', () => ({
|
||||
config: {
|
||||
serveFrontend: true,
|
||||
frontendDistPath: '/unused-in-tests',
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
vi.mock('../config', () => ({
|
||||
config: {
|
||||
serveFrontend: true,
|
||||
frontendDistPath: '/unused-in-tests',
|
||||
ruffleDataPath: '/unused-in-tests/ruffle',
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the full backend suite and typecheck**
|
||||
|
||||
Run from `backend/`: `npx vitest run`
|
||||
|
||||
Then run from the repository root: `npm run typecheck`
|
||||
|
||||
Expected: no failures and no type errors. `startupOrdering.test.ts` in particular must stay green — a missing-directory static mount with `fallthrough: true` calls `next()` instead of throwing.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
npx prettier --write backend/src/middleware/frontendServing.ts backend/src/middleware/ruffleServing.test.ts backend/src/middleware/startupOrdering.test.ts
|
||||
git add backend/src/middleware/frontendServing.ts backend/src/middleware/ruffleServing.test.ts backend/src/middleware/startupOrdering.test.ts
|
||||
git commit -m "fix(ruffle): serve the persisted install ahead of the bundled one"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Deployment configuration and documentation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docker-compose.dev.yml:28-40`
|
||||
- Modify: `docker-compose.yml:29-33`
|
||||
- Modify: `docs/09-deployment/environment-variables.md:113-116`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: the `RUFFLE_DATA_PATH` variable name and the `/app/data/ruffle` default from Task 1.
|
||||
- Produces: nothing.
|
||||
|
||||
**Implementer:** dcc-superpower-companions:impl-sonnet-medium
|
||||
**Evaluation:** files 1 - spec 0 - coupling 1 - risk 0 = 2
|
||||
|
||||
- [ ] **Step 1: Set the variable in the dev compose file**
|
||||
|
||||
`docker-compose.dev.yml` runs `NODE_ENV=development` with `SERVE_FRONTEND=true`, so the production-keyed default resolves to a relative path inside the container and misses the `backend-db:/app/data` volume. In `docker-compose.dev.yml`, find:
|
||||
|
||||
```yaml
|
||||
# Matches the read-only mount above. Set explicitly because the backend's
|
||||
# fallback only points here when NODE_ENV=production.
|
||||
- FLASHPOINT_PATH=/data/flashpoint
|
||||
```
|
||||
|
||||
Insert directly after it:
|
||||
|
||||
```yaml
|
||||
# Same reason as FLASHPOINT_PATH above: this container runs
|
||||
# NODE_ENV=development, so the production default would put Ruffle outside
|
||||
# the data volume and lose every update on recreate.
|
||||
- RUFFLE_DATA_PATH=/app/data/ruffle
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update both volume comments**
|
||||
|
||||
In `docker-compose.yml`, find:
|
||||
|
||||
```yaml
|
||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
|
||||
- ${DATA_PATH:-./data}:/app/data
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```yaml
|
||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
|
||||
# Holds user.db and the installed Ruffle. Without this mount an in-app
|
||||
# Ruffle update is lost the next time the container is recreated.
|
||||
- ${DATA_PATH:-./data}:/app/data
|
||||
```
|
||||
|
||||
In `docker-compose.dev.yml`, find:
|
||||
|
||||
```yaml
|
||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
|
||||
- backend-db:/app/data
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```yaml
|
||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
|
||||
# Holds user.db and the installed Ruffle. Without this mount an in-app
|
||||
# Ruffle update is lost the next time the container is recreated.
|
||||
- backend-db:/app/data
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Document the variable**
|
||||
|
||||
In `docs/09-deployment/environment-variables.md`, find this table:
|
||||
|
||||
```markdown
|
||||
| Variable | Default | Description |
|
||||
| -------------------- | ----------------------------- | -------------------------------------------------------------- |
|
||||
| `SERVE_FRONTEND` | true in production, else false | Serve the built frontend and SPA fallback from the backend |
|
||||
| `FRONTEND_DIST_PATH` | `<backend>/../frontend/dist` | Location of the built frontend (rarely needed) |
|
||||
```
|
||||
|
||||
Add one row to it:
|
||||
|
||||
```markdown
|
||||
| `RUFFLE_DATA_PATH` | `/app/data/ruffle` in production | Where Ruffle is installed. Must sit on a mounted volume, or an in-app update is lost when the container is recreated |
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify both compose files still parse**
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml config --quiet
|
||||
docker compose -f docker-compose.dev.yml config --quiet
|
||||
```
|
||||
|
||||
Expected: both exit 0 and print nothing. Each file requires `FLASHPOINT_HOST_PATH` and `JWT_SECRET`; if the repository has no `.env`, supply throwaway values inline:
|
||||
|
||||
```bash
|
||||
FLASHPOINT_HOST_PATH=/tmp/fp JWT_SECRET=x docker compose -f docker-compose.yml config --quiet
|
||||
FLASHPOINT_HOST_PATH=/tmp/fp JWT_SECRET=x docker compose -f docker-compose.dev.yml config --quiet
|
||||
```
|
||||
|
||||
If the Docker CLI is unavailable in this environment, say so rather than skipping silently, and parse both files directly instead:
|
||||
|
||||
```bash
|
||||
node -e "const y=require('yaml');const fs=require('fs');for(const f of ['docker-compose.yml','docker-compose.dev.yml']){y.parse(fs.readFileSync(f,'utf8'));console.log(f,'ok')}"
|
||||
```
|
||||
|
||||
The `yaml` package is already present in the repository's `node_modules` as a transitive dependency. This proves the YAML parses; it does not check Compose semantics, so report which of the two checks you ran.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
npx prettier --write docker-compose.yml docker-compose.dev.yml docs/09-deployment/environment-variables.md
|
||||
git add docker-compose.yml docker-compose.dev.yml docs/09-deployment/environment-variables.md
|
||||
git commit -m "docs(ruffle): document the persisted install path"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After Task 4, confirm the whole change from the repository root:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then from `backend/`: `npx vitest run`
|
||||
|
||||
Expected: typecheck clean, build succeeds, backend suite green.
|
||||
|
||||
Manual confirmation in a container, which is what the reported bug needs:
|
||||
|
||||
1. `docker compose -f docker-compose.dev.yml up -d --build`
|
||||
2. Settings → Update → update Ruffle, and note the version.
|
||||
3. `docker compose -f docker-compose.dev.yml up -d --force-recreate`
|
||||
4. Settings → Update still reports the updated version, and a hard-refreshed
|
||||
browser loads it.
|
||||
@@ -1,184 +0,0 @@
|
||||
# Downloaded games: page, filter, and settings
|
||||
|
||||
Date: 2026-08-11
|
||||
|
||||
## Goal
|
||||
|
||||
1. A "Downloaded" sidebar entry and page listing only downloaded games.
|
||||
2. A downloaded filter in the filter panel on the existing browse pages.
|
||||
3. An admin setting to show or hide the Downloaded page.
|
||||
4. An admin setting that lets guests see the Downloaded page, offered only when
|
||||
the page itself is enabled.
|
||||
5. An admin setting that turns the downloaded filter on by default.
|
||||
|
||||
## Definition of "downloaded"
|
||||
|
||||
A game is downloaded when it has at least one `game_data` row with
|
||||
`presentOnDisk = 1`.
|
||||
|
||||
Games with no `game_data` rows at all — legacy titles served from htdocs — can
|
||||
never be downloaded under this definition and never appear on the page. This
|
||||
matches the existing per-game badge, which reads the same flag through the
|
||||
`MAX(presentOnDisk)` post-processing in `GameService.searchGames`.
|
||||
|
||||
## Keeping the flag truthful
|
||||
|
||||
`presentOnDisk` is currently written only by the Flashpoint Launcher and by the
|
||||
explicit Download button (`DownloadManager` → `GameDatabaseUpdater.markAsDownloaded`).
|
||||
Three gaps must close before the flag can drive a whole page.
|
||||
|
||||
**Play-path downloads never set it.** `GameZipServer.downloadAndMountInBackground`
|
||||
writes the ZIP and returns without touching the database. On success it will call
|
||||
`GameDatabaseUpdater.markAsDownloaded`. The `game_data.id` is already loaded by
|
||||
`GameDataService.getGameDataEntry`; thread it through the `mountZip` params rather
|
||||
than re-querying by `gameId` + `dateAdded`. Mark the row even when `sha256` is
|
||||
absent, since `GameDataDownloader` only verifies a checksum when one exists and the
|
||||
file is otherwise a complete, mountable download.
|
||||
|
||||
**Local-copy mode erases the flag.** With `ENABLE_LOCAL_DB_COPY=true`, queries and
|
||||
our writes hit `config.localDbPath`, and `DatabaseService.syncAndReload` copies the
|
||||
source over that file whenever the Launcher's database changes. A new
|
||||
`DownloadedReconciler` service restores truth from disk:
|
||||
|
||||
- Scan `config.flashpointGamesPath` for files matching `{uuid}-{timestamp}.zip`,
|
||||
the deterministic name produced by `GameDataDownloader.getFilename`.
|
||||
- Resolve each to its `game_data` row by `gameId` plus the timestamp derived from
|
||||
`dateAdded`, and bulk-set `presentOnDisk = 1` in a single transaction, skipping
|
||||
rows already flagged.
|
||||
- Update `game.activeDataOnDisk` where the marked row is the game's active data.
|
||||
- Run once at startup after `DatabaseService` initialization, and again after every
|
||||
`syncAndReload`. Both runs are fire-and-forget with a `.catch()`; an unreadable
|
||||
games directory logs a warning and yields zero marks.
|
||||
|
||||
The reconciler only ever marks. It never clears the flag for a missing ZIP, because
|
||||
the Launcher can store game data outside `flashpointGamesPath` and we would be
|
||||
overwriting its state with a false negative.
|
||||
|
||||
This scan also backfills every game downloaded through the Play path before this
|
||||
feature ships, which is otherwise the largest population of invisible downloads.
|
||||
|
||||
**Our own writes trigger a full reload.** In non-copy mode the write lands in the
|
||||
file `DatabaseService` watches, so the watcher fires, reopens the connection, and
|
||||
clears the search, flash-SWF, and filter-options caches — per download. Add
|
||||
`DatabaseService.noteSelfWrite()`, which re-stats the source and advances
|
||||
`lastModifiedTime` so `syncAndReload` short-circuits, and call it from
|
||||
`markAsDownloaded` and the reconciler. The caller then clears only the search and
|
||||
filter-options caches, so a freshly downloaded game appears immediately.
|
||||
|
||||
A Launcher write landing in the same millisecond as ours could be swallowed by this
|
||||
check. The consequence is a delayed reload, not data loss, and the next Launcher
|
||||
write recovers it.
|
||||
|
||||
## Backend filter
|
||||
|
||||
`downloaded?: boolean` joins the POST `/api/games` body schema, `GameSearchQuery`,
|
||||
and the filter-options query schema. `true` restricts to downloaded games;
|
||||
`false` and `undefined` both mean "no restriction", and the frontend omits the
|
||||
field when the filter is off.
|
||||
|
||||
In `GameService.searchGames` the condition is:
|
||||
|
||||
```sql
|
||||
AND EXISTS (SELECT 1 FROM game_data gd WHERE gd.gameId = g.id AND gd.presentOnDisk = 1)
|
||||
```
|
||||
|
||||
`buildFilterOptionsConditions` gets the same condition so dropdown values and the
|
||||
year range narrow to downloaded games; qualify the outer column with its table name
|
||||
there, since those queries have no alias and `game_data` has its own `id` column.
|
||||
|
||||
Both `GameSearchCache.generateCacheKey` and `GameService.getFilterOptionsCacheKey`
|
||||
include `downloaded`, normalized with `??` so `undefined` and `false` stay distinct.
|
||||
Add `'downloaded'` to the `filters` array in the search route's `logActivity`
|
||||
callback so the recorded filter count stays accurate.
|
||||
|
||||
## Frontend filter
|
||||
|
||||
`GameFilters.downloaded?: boolean` carries the value to the API.
|
||||
`FilterUrlParams.downloaded?: '0' | '1'` carries it in the URL under the unused
|
||||
single-character key `w`, guarded with `!== undefined` rather than truthiness so an
|
||||
explicit off survives `toUrlSafeFormat`.
|
||||
|
||||
The value is tri-state: `w.1` on, `w.0` off, absent means "use the admin default".
|
||||
`GameBrowseLayout` resolves it once and passes a plain boolean down.
|
||||
|
||||
Because absent means "default", removal cannot clear the parameter when the default
|
||||
is on — the filter would immediately reapply. When the admin default is on,
|
||||
`handleRemoveChip` and `handleClearAllFilters` write `w.0` instead of dropping the
|
||||
key; when it is off, they clear it as every other filter does. `CATEGORY_TO_ABBR`
|
||||
gains `Downloaded: 'W'` and `categoryToParamKeys` gains the matching entry so the
|
||||
chip participates in the existing filter hierarchy.
|
||||
|
||||
A bookmarked URL with no `w` changes meaning if an admin later flips the default.
|
||||
That is inherent to the tri-state and accepted.
|
||||
|
||||
`FilterPanel` renders a "Downloaded only" switch beside the year range in both the
|
||||
desktop and mobile rows, and an active filter chip. A new `showDownloadedFilter`
|
||||
prop hides the switch on the Downloaded page, where the filter is the page.
|
||||
|
||||
## Downloaded page
|
||||
|
||||
Route `/downloaded` renders `DownloadedView`, a thin wrapper over
|
||||
`GameBrowseLayout` with the filter pinned on and no library restriction, so results
|
||||
span arcade and theatre. `GameBrowseLayout`'s `library` prop becomes optional and it
|
||||
gains `forceDownloaded`.
|
||||
|
||||
The page passes `sectionKey={null}`, so game links resolve to the existing
|
||||
`/games/:id` detail route. No new per-section detail or play routes.
|
||||
|
||||
## Settings
|
||||
|
||||
Migration `004_downloaded_settings.sql` seeds three public boolean settings in the
|
||||
`features` category, following the existing `enable_*` naming:
|
||||
|
||||
| Key | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `features.enable_downloaded_page` | 1 | Sidebar entry and `/downloaded` route |
|
||||
| `features.enable_downloaded_page_for_guests` | 0 | Guests may see the page |
|
||||
| `features.enable_downloaded_filter_default` | 0 | Browse pages start filtered |
|
||||
|
||||
`useFeatureFlags` exposes `enableDownloadedPage` with the usual admin bypass, so an
|
||||
admin cannot lock themselves out. The other two are read as `=== true`: they default
|
||||
off, and the hook's `?? true` fallback would otherwise invert them during the
|
||||
public-settings loading window and whenever the key is missing.
|
||||
|
||||
`ProtectedRoute` gains `'enableDownloadedPage'` in its `requireFeature` union and a
|
||||
new `requireFeatureForGuests` prop, checked only when the viewer is a guest, so a
|
||||
guest deep-linking to `/downloaded` with the guest flag off is redirected to
|
||||
`/unauthorized`. A flag check alone would let them through, since guests satisfy
|
||||
`requireAuth`.
|
||||
|
||||
These flags are presentation, not access control: `POST /api/games` is open to
|
||||
guests and accepts `downloaded` regardless, and the page shows no data a guest
|
||||
cannot already reach by filtering Browse. No backend enforcement is added.
|
||||
|
||||
`FeaturesSettingsTab` gains the three switches, with the guest row rendered only
|
||||
while the page flag is on. Its stored value persists while hidden and resurfaces if
|
||||
the page is re-enabled.
|
||||
|
||||
## Sidebar
|
||||
|
||||
The Downloaded entry sits in the Library section with Favorites. That block is
|
||||
currently hidden wholesale for guests, so its items never check guest status
|
||||
themselves. Relaxing the block requires giving each item its own predicate:
|
||||
Favorites and both playlist entries become explicitly `!isGuest`, Downloaded
|
||||
becomes `enableDownloadedPage && (!isGuest || enableDownloadedPageForGuests)`, and
|
||||
the section renders when any item survives. The existing
|
||||
`isViewingSharedPlaylistWithoutGuestAccess` case is unaffected.
|
||||
|
||||
## Testing
|
||||
|
||||
- `GameService.searchGames` returns only flagged games with `downloaded: true`, and
|
||||
is unaffected when the field is absent.
|
||||
- Filter options narrow when `downloaded` is set, and cache keys distinguish
|
||||
`undefined`, `false`, and `true`.
|
||||
- `DownloadedReconciler` maps ZIP filenames to the right `game_data` rows, ignores
|
||||
unrelated files, and marks nothing when the directory is unreadable.
|
||||
- `filterUrlCompression` round-trips `w.0` and `w.1`, and an explicit off survives
|
||||
`buildFilterSearchParams`.
|
||||
- Chip removal writes an explicit off when the admin default is on.
|
||||
|
||||
## Documentation
|
||||
|
||||
Update `docs/06-api-reference/games-api.md` (search and filter-options parameters),
|
||||
`docs/06-api-reference/settings-api.md` (three new settings), and
|
||||
`docs/10-features/02-game-browsing-filtering.md` (the filter and the page).
|
||||
@@ -1,234 +0,0 @@
|
||||
# Web app update check on the Update page
|
||||
|
||||
Date: 2026-08-12
|
||||
|
||||
## Goal
|
||||
|
||||
An admin opening Settings → Update sees whether a newer release of Flashpoint
|
||||
Web exists, what changed in it, and how to upgrade.
|
||||
|
||||
The card notifies only. It never pulls an image, restarts a container, or writes
|
||||
anything: self-updating from inside the container needs the Docker socket
|
||||
mounted, which hands the container root-equivalent control of the host.
|
||||
|
||||
## What counts as an update
|
||||
|
||||
The running version comes from `APP_VERSION`, an environment variable baked into
|
||||
the image from the `VERSION` build arg that CI already passes (see
|
||||
`.github/workflows/docker-build-push.yml`). This mirrors `VITE_APP_VERSION`,
|
||||
which the frontend build consumes for the same value.
|
||||
|
||||
A version counts as a release build only when it is three clean numbers, with an
|
||||
optional leading `v` — `1.0.38` or `v1.0.38`. Anything else is an **unreleased
|
||||
build**: the variable unset, `dev` from a local `docker build`, `latest` from a
|
||||
`workflow_dispatch` run, a release candidate like `1.1.0-rc1`, or a
|
||||
`git describe` string like `1.0.38-5-gabc123`. An unreleased build never claims
|
||||
to be behind, because its commits may already include everything in the latest
|
||||
release.
|
||||
|
||||
The comparison target is GitHub's
|
||||
`/repos/darkraise/flashpoint-web/releases/latest`, which excludes prereleases by
|
||||
definition. No prerelease filtering is needed, and release candidates never
|
||||
surface.
|
||||
|
||||
Verified against the live endpoint on 2026-08-12: it answers 200 with
|
||||
`tag_name: "v1.0.38"`, `prerelease: false`, `published_at`, `html_url`, and a
|
||||
markdown `body`. The tag carries a leading `v` that `APP_VERSION` does not, so
|
||||
both sides are stripped before comparing — the tag always has one, and a manual
|
||||
`docker build --build-arg VERSION=v1.0.38` would otherwise be misread as
|
||||
unreleased.
|
||||
|
||||
## The two version sources must not contradict each other
|
||||
|
||||
The frontend already resolves its own version at build time
|
||||
(`frontend/vite.config.ts`, `frontend/src/lib/version.ts`) and shows it on the
|
||||
General tab as "Web App Version". This design adds a second, server-side source,
|
||||
and the two sit one tab apart.
|
||||
|
||||
In Docker they agree: both build stages receive the same `VERSION` build arg. In
|
||||
a source checkout they can differ — the frontend falls back to `git describe`,
|
||||
while the backend has no `APP_VERSION` at all — so the General tab could read
|
||||
`1.0.38` while the card reads "unreleased build". The card's copy therefore
|
||||
attributes the state to the server rather than to the operator: "The server did
|
||||
not report a released version, so update checks are off."
|
||||
|
||||
`APP_VERSION` is documented in `docs/09-deployment/environment-variables.md` as
|
||||
an optional operator-settable variable, so a non-Docker deployment can opt into
|
||||
update checks.
|
||||
|
||||
## Backend
|
||||
|
||||
### `compareVersions(a, b)` — `backend/src/utils/version.ts`
|
||||
|
||||
Returns a negative number, zero, or a positive number by comparing major, minor,
|
||||
and patch numerically, after stripping an optional leading `v` from each side.
|
||||
Returns `null` when either input is not exactly three numeric parts, which is
|
||||
what routes an unreleased build away from the comparison. Numeric comparison
|
||||
matters: string ordering puts `1.0.9` above `1.0.38`.
|
||||
|
||||
A `null` result from a malformed *latest* tag — a hand-cut release tagged `v2.0`
|
||||
— yields `updateAvailable: false`. Reporting nothing is preferable to reporting
|
||||
a wrong direction.
|
||||
|
||||
### `AppUpdateService` — `backend/src/services/AppUpdateService.ts`
|
||||
|
||||
One public method, `getUpdateInfo(force = false)`, returning:
|
||||
|
||||
```ts
|
||||
interface AppUpdateInfo {
|
||||
currentVersion: string | null; // null when APP_VERSION is unset
|
||||
latestVersion: string | null; // null when GitHub has never been reached
|
||||
updateAvailable: boolean;
|
||||
isUnreleasedBuild: boolean;
|
||||
publishedAt: string | null;
|
||||
releaseUrl: string | null;
|
||||
changelog: string | null;
|
||||
checkedAt: string | null; // when the held result was fetched
|
||||
lastCheckFailed: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
The result is cached in memory for one hour and shared by every admin. The cache
|
||||
holds the last *successful* result; a failed check returns that result with
|
||||
`lastCheckFailed: true` rather than discarding it.
|
||||
|
||||
**Concurrency.** The in-flight fetch promise is stored and returned to
|
||||
concurrent callers, so a cold cache hit by several requests at once produces one
|
||||
GitHub call, not several.
|
||||
|
||||
**Forced refresh.** `force` bypasses the one-hour cache but not a 60-second
|
||||
floor: a forced check within 60 seconds of the last fetch returns the held
|
||||
result. `rateLimitStandard` is 100 requests per 60-second window
|
||||
(`backend/src/config.ts`, `RATE_LIMIT_MAX_REQUESTS`), which is no protection for
|
||||
a 60-per-hour unauthenticated GitHub quota that this feature shares with the
|
||||
star-count proxy and the Ruffle update check. The floor is what keeps the quota
|
||||
intact.
|
||||
|
||||
`isUnreleasedBuild: true` still fetches and reports `latestVersion` and the
|
||||
changelog for reference, but forces `updateAvailable: false`.
|
||||
|
||||
### Failure handling
|
||||
|
||||
The route always answers 200. A total failure — no cached result and GitHub
|
||||
unreachable — returns `latestVersion: null` with `lastCheckFailed: true`, and
|
||||
the card renders its failed state.
|
||||
|
||||
It does **not** throw `AppError(502)`, because the axios interceptor in
|
||||
`frontend/src/lib/api/client.ts` fires a global "Server error occurred" toast for
|
||||
any status at or above 500. A card that renders its own inline error would
|
||||
double up with that toast, which is what the Ruffle card does today.
|
||||
|
||||
`backend/src/routes/github.ts` is the precedent for serving a stale result, but
|
||||
it is a weaker version of this design and should not be copied line for line: it
|
||||
throws a plain `Error`, has no request timeout, and its stale path covers only a
|
||||
non-OK response, so a network failure or hang bypasses the cache entirely. Here
|
||||
the `try` wraps the whole fetch, and the request carries an explicit timeout.
|
||||
|
||||
### `GET /api/updates/app`
|
||||
|
||||
Added to `backend/src/routes/updates.ts` behind `authenticate` and
|
||||
`requirePermission('settings.update')` from `../middleware/rbac`. That matches
|
||||
the tab's own gate in `UpdateSettingsTab.tsx`; note that two GETs in that file
|
||||
use `settings.read`, so "the same as its neighbours" is not exact.
|
||||
|
||||
The handler returns the bare `AppUpdateInfo` object — `res.json(info)` — as
|
||||
every endpoint in `updates.ts` does and as `updatesApi` expects. The
|
||||
`{success, data}` envelope in `github.ts` is that router's convention, not this
|
||||
one.
|
||||
|
||||
`?refresh=true` sets `force`, read as `req.query.refresh === 'true'`. Any other
|
||||
value, array, or object is falsy, so no schema or bounds check is warranted; the
|
||||
60-second floor is the real control. `rateLimitStandard` already applies to the
|
||||
whole router.
|
||||
|
||||
### `config.appVersion`
|
||||
|
||||
`process.env.APP_VERSION?.trim() || null`, added to `backend/src/config.ts`
|
||||
beside the Flashpoint version fields. `||` is deliberate here and must not be
|
||||
"corrected" to `??`: an empty or whitespace-only variable has to collapse to
|
||||
`null`, which `??` would not do.
|
||||
|
||||
### Dockerfile
|
||||
|
||||
The final stage already declares `ARG VERSION=dev` for the image label. It gains
|
||||
`ENV APP_VERSION=$VERSION`, since an `ARG` alone does not survive into the
|
||||
running container.
|
||||
|
||||
## Frontend
|
||||
|
||||
`AppUpdateCard` — `frontend/src/components/settings/AppUpdateCard.tsx` — renders
|
||||
first in `UpdateSettingsTab`, above the metadata and Ruffle cards, admin-only
|
||||
like both of them.
|
||||
|
||||
A `useQuery` on mount performs the check. The Check button cannot be a
|
||||
`refetch()`, which reuses the original query function and so cannot add
|
||||
`refresh=true`; it is a `useMutation` calling the endpoint with the flag, writing
|
||||
its result back through `queryClient.setQueryData`. This is the shape the Ruffle
|
||||
card already uses.
|
||||
|
||||
The card reuses the vocabulary of `RuffleManagementCard`: a collapsible
|
||||
changelog rendered with `react-markdown` and `remark-gfm`, `FormattedDate` for
|
||||
the release date, the same button placement and toast behaviour. No sanitizer is
|
||||
added — without `rehype-raw`, react-markdown escapes raw HTML, and the source is
|
||||
the project's own CI-generated release notes.
|
||||
|
||||
Five states:
|
||||
|
||||
| State | Condition | Shows |
|
||||
| ---------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| Update available | `updateAvailable` | Both versions, release date, changelog expanded, link to the release, upgrade commands |
|
||||
| Up to date | versions match | Current version and when it was checked |
|
||||
| Unreleased build | `isUnreleasedBuild` | "The server did not report a released version, so update checks are off", plus the latest release for reference |
|
||||
| Stale | `lastCheckFailed` with a `latestVersion` | The held result, labelled as a check that failed at `checkedAt` |
|
||||
| Check failed | `lastCheckFailed`, no `latestVersion` | A muted error and a retry button |
|
||||
|
||||
The upgrade command is `docker compose pull && docker compose up -d`, matching
|
||||
the deployment `docker-compose.yml` documents. That file pins
|
||||
`image: darkraise/flashpoint-web:${IMAGE_TAG:-latest}`, so the card adds one
|
||||
line: an operator who set `IMAGE_TAG` to a fixed version must raise it first, or
|
||||
the pull is a no-op.
|
||||
|
||||
The release body generated by CI opens with its own "## Docker Image" section
|
||||
carrying a `docker pull` line, so the changelog repeats part of this. The card
|
||||
still states the commands itself, because the compose commands are the actual
|
||||
upgrade and the changelog is collapsible.
|
||||
|
||||
The API call goes through `updatesApi` in `frontend/src/lib/api/updates.ts`, with
|
||||
`AppUpdateInfo` declared there and re-exported from `lib/api/index.ts`, as the
|
||||
other update types are.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend unit tests in the repo's existing style, with no test for the card —
|
||||
`components/settings` has no test coverage today and this change does not
|
||||
introduce the pattern.
|
||||
|
||||
- `compareVersions`: equal, newer, older, multi-digit ordering (`1.0.38` above
|
||||
`1.0.9`), a leading `v` on either or both sides, and malformed inputs (`dev`,
|
||||
`latest`, `1.0.38-5-gabc123`, `1.0`, empty).
|
||||
- `AppUpdateService` with a mocked fetch: update available, up to date,
|
||||
unreleased build, malformed latest tag, cache hit inside the hour, forced
|
||||
refresh honoured after the 60-second floor and ignored inside it, one GitHub
|
||||
call for concurrent cold-cache requests, a held result returned with
|
||||
`lastCheckFailed` after a failure, and a 200 with `latestVersion: null` when a
|
||||
failure has no cache to fall back on.
|
||||
|
||||
## Decisions taken
|
||||
|
||||
- **Notify only.** Self-update requires the Docker socket; a restart-only button
|
||||
cannot pull, so it would only ever help someone who had already pulled.
|
||||
- **The repository is hardcoded** to `darkraise/flashpoint-web`, as it already is
|
||||
in the star-count proxy. A fork wanting its own release feed would need this
|
||||
made configurable; nothing here is designed to prevent that later.
|
||||
- **Docker Compose is the documented deployment**, so it is the only upgrade
|
||||
command shown.
|
||||
- **No notification badge** elsewhere in the UI, and no scheduled background
|
||||
check. The cached on-open check covers the stated goal; a `JobScheduler` job
|
||||
would be the way to add a badge later.
|
||||
|
||||
## Known limitation, not addressed here
|
||||
|
||||
A `workflow_dispatch` run of the build workflow tags the image `:latest` and
|
||||
passes `VERSION=latest`, so that image reports an unreleased build. The card
|
||||
makes a pre-existing CI wart visible; fixing it means changing what the manual
|
||||
trigger publishes, which is out of scope for this feature.
|
||||
@@ -1,131 +0,0 @@
|
||||
# Persistent Ruffle installation
|
||||
|
||||
Date: 2026-08-13
|
||||
|
||||
## Goal
|
||||
|
||||
A Ruffle version installed through Settings → Update survives container
|
||||
recreation. Today it does not: the update lands in the container's writable
|
||||
layer, and the next `docker compose up -d` after an image pull or a config
|
||||
change throws it away.
|
||||
|
||||
## Why it reverts
|
||||
|
||||
`RuffleService` installs into `config.frontendDistPath/ruffle` — `/app/frontend/dist/ruffle`
|
||||
inside the container. The only mounted volumes are `/data/flashpoint`,
|
||||
`/app/data`, and `/app/logs`, so that path lives entirely in the overlay
|
||||
writable layer.
|
||||
|
||||
`frontend/public/ruffle/` is committed to the repository at
|
||||
`0.2.0-nightly.2026.1.29`. Vite copies `public/` into `frontend/dist/` at build
|
||||
time and the Dockerfile bakes that directory into the image. When a recreate
|
||||
discards the writable layer, the baked copy is what remains.
|
||||
|
||||
Startup only downloads when `verifyInstallation()` is false
|
||||
(`backend/src/server.ts:302`). The baked copy satisfies that check, so the
|
||||
server logs the stale version and keeps it. The scheduled `ruffle-update` job
|
||||
would eventually re-apply the update, but only if that job is enabled.
|
||||
|
||||
A plain `docker restart` preserves the writable layer and does not trigger this.
|
||||
The recreate paths do: `docker compose up -d` after an image pull or any change
|
||||
to the resolved service config, `down` then `up`, `--force-recreate`, and the
|
||||
update actions in Portainer and Unraid.
|
||||
|
||||
## Install location
|
||||
|
||||
A new `config.ruffleDataPath` names the install directory:
|
||||
|
||||
```
|
||||
process.env.RUFFLE_DATA_PATH ??
|
||||
(NODE_ENV === 'production' ? '/app/data/ruffle' : <repo>/backend/data/ruffle)
|
||||
```
|
||||
|
||||
`RuffleService` swaps only its `serveFrontend` branch to this path. The other
|
||||
branch keeps writing to `frontend/public/ruffle`, because in a source checkout
|
||||
Vite serves `public/` and an install anywhere else would not be reachable.
|
||||
|
||||
`docker-compose.dev.yml` sets `RUFFLE_DATA_PATH=/app/data/ruffle` explicitly. It
|
||||
runs `NODE_ENV=development` with `SERVE_FRONTEND=true`, so the production-keyed
|
||||
default would miss its `backend-db:/app/data` volume. The file already sets
|
||||
`FLASHPOINT_PATH` for exactly this reason, and the new variable follows that
|
||||
precedent. Production compose needs no change: `NODE_ENV=production` already
|
||||
resolves to the right path.
|
||||
|
||||
The entrypoint chowns `/app/data` to the app user on every start, so a non-default
|
||||
`PUID` can write the new directory without further work.
|
||||
|
||||
## Seeding a fresh install
|
||||
|
||||
`RuffleService.ensureInstalled()` replaces the inline block at
|
||||
`backend/src/server.ts:299-314` and resolves in three steps:
|
||||
|
||||
1. `ruffle.js` exists at the data path — log the version and stop.
|
||||
2. Otherwise the image's bundled `frontend/dist/ruffle` exists — copy it into
|
||||
the data path via the existing private `copyDirectory`.
|
||||
3. Otherwise — download the latest release from GitHub, which is the current
|
||||
behaviour.
|
||||
|
||||
Seeding from the bundle rather than downloading means Flash works the moment a
|
||||
fresh container is up, and works at all on a host that cannot reach GitHub. The
|
||||
copy is roughly 20 MB and happens once per persisted volume, not once per
|
||||
container.
|
||||
|
||||
The call stays fire-and-forget in the background. Awaiting it would hold the
|
||||
port closed for the duration of a download, which is the regression the current
|
||||
comment at `server.ts:296-298` records.
|
||||
|
||||
## Serving
|
||||
|
||||
`registerFrontendServing` mounts the data path at `/ruffle` **before**
|
||||
`express.static(distPath)`, so the persisted copy shadows the baked one. The
|
||||
mount uses `fallthrough: true`: if the persisted tree is missing a file, the
|
||||
baked `dist/ruffle` still answers rather than 404ing into a dead player.
|
||||
|
||||
The frontend needs no change. `RufflePlayer.tsx` already loads
|
||||
`/ruffle/ruffle.js` and sets `publicPath: '/ruffle/'`, both of which the new
|
||||
mount serves.
|
||||
|
||||
## Cache headers
|
||||
|
||||
The dist handler currently serves everything with `maxAge: '1y', immutable: true`,
|
||||
including `ruffle/ruffle.js`, which is not content-hashed. A browser that has
|
||||
loaded the page once will therefore keep the old loader for a year, so
|
||||
persistence alone would not make an update visible to returning users.
|
||||
|
||||
A shared helper decides the policy from the basename: `immutable, max-age=1y`
|
||||
when it contains a hex run of 16 or more characters, otherwise
|
||||
`no-cache, no-store, must-revalidate`. Ruffle's chunks carry such a run
|
||||
(`core.ruffle.4ca82b563e9711217164.js`, `f2a570ccf4468b20d95a.wasm`) while its
|
||||
stable entry points do not (`ruffle.js`, `ruffle.js.map`, `package.json`).
|
||||
Detecting the hash rather than listing the stable names keeps the rule correct
|
||||
when a future Ruffle release adds a file.
|
||||
|
||||
The helper applies to both the new `/ruffle` mount and to `ruffle/` paths under
|
||||
the dist handler, so the fallback cannot reintroduce a stale loader.
|
||||
|
||||
## Testing
|
||||
|
||||
`RuffleService.test.ts` mocks `../config`; that mock gains `ruffleDataPath` and
|
||||
the existing `ruffleDir()` helper points at it. The update tests are otherwise
|
||||
unchanged — they exercise the rename and copy fallbacks, which the new path does
|
||||
not alter.
|
||||
|
||||
New cases cover `ensureInstalled()`:
|
||||
|
||||
- an existing install is left alone and no download is attempted;
|
||||
- an empty data path with a bundled copy present is seeded from the bundle, and
|
||||
no download is attempted;
|
||||
- an empty data path with no bundle falls through to the download.
|
||||
|
||||
A new `frontendServing` test asserts the `/ruffle` mount is registered ahead of
|
||||
the dist handler, that `ruffle.js` comes back revalidating, and that a hashed
|
||||
chunk comes back immutable.
|
||||
|
||||
## Out of scope
|
||||
|
||||
`config.userDbPath` and `config.tempDownloadPath` use the same
|
||||
`NODE_ENV === 'production'` keying as the new variable. In the dev compose
|
||||
container that evaluates to `development`, so `user.db` lands at
|
||||
`/app/backend/user.db` and the `backend-db:/app/data` volume goes unused — dev
|
||||
container app data is not persisted today. Real, but unrelated to Ruffle, and
|
||||
touching database paths carries risk this change does not need.
|
||||
+9
-67
@@ -1,4 +1,4 @@
|
||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useEffect, lazy, Suspense } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MainLayout } from './components/layout/MainLayout';
|
||||
@@ -41,12 +41,6 @@ const HomeView = lazy(() => import('./views/HomeView').then((m) => ({ default: m
|
||||
const BrowseView = lazy(() =>
|
||||
import('./views/BrowseView').then((m) => ({ default: m.BrowseView }))
|
||||
);
|
||||
const FlashGamesView = lazy(() =>
|
||||
import('./views/FlashGamesView').then((m) => ({ default: m.FlashGamesView }))
|
||||
);
|
||||
const HTML5GamesView = lazy(() =>
|
||||
import('./views/HTML5GamesView').then((m) => ({ default: m.HTML5GamesView }))
|
||||
);
|
||||
const AnimationsView = lazy(() =>
|
||||
import('./views/AnimationsView').then((m) => ({ default: m.AnimationsView }))
|
||||
);
|
||||
@@ -95,6 +89,12 @@ const ActivitiesView = lazy(() =>
|
||||
import('./views/ActivitiesView').then((m) => ({ default: m.ActivitiesView }))
|
||||
);
|
||||
|
||||
/** The old /flash and /html5 sections are now part of /browse */
|
||||
function LegacySectionRedirect() {
|
||||
const { pathname, search } = useLocation();
|
||||
return <Navigate to={pathname.replace(/^\/(flash|html5)/, '/browse') + search} replace />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, isGuest, clearAuth, setGuestMode } = useAuthStore();
|
||||
const isServerStarting = useServerStatusStore((state) => state.isStarting);
|
||||
@@ -289,6 +289,8 @@ function App() {
|
||||
|
||||
{/* ==================== STANDALONE ==================== */}
|
||||
<Route path="/maintenance" element={<MaintenancePage />} />
|
||||
<Route path="/flash/*" element={<LegacySectionRedirect />} />
|
||||
<Route path="/html5/*" element={<LegacySectionRedirect />} />
|
||||
|
||||
{/* ==================== PORTAL LAYOUT ==================== */}
|
||||
<Route element={<PortalLayout />}>
|
||||
@@ -304,26 +306,6 @@ function App() {
|
||||
/>
|
||||
|
||||
{/* Public/guest accessible routes */}
|
||||
<Route
|
||||
path="/flash"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={false}>
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<FlashGamesView />
|
||||
</Suspense>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/html5"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={false}>
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<HTML5GamesView />
|
||||
</Suspense>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/browse"
|
||||
element={
|
||||
@@ -346,46 +328,6 @@ function App() {
|
||||
/>
|
||||
|
||||
{/* Section-based game routes - must come before /games/:id */}
|
||||
<Route
|
||||
path="/flash/:id"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<GamePage />
|
||||
</Suspense>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/flash/:id/play"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="games.play">
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<GamePage autoPlay />
|
||||
</Suspense>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/html5/:id"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<GamePage />
|
||||
</Suspense>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/html5/:id/play"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="games.play">
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<GamePage autoPlay />
|
||||
</Suspense>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/animations/:id"
|
||||
element={
|
||||
|
||||
@@ -35,7 +35,7 @@ interface GameBrowseLayoutProps {
|
||||
platform?: string;
|
||||
headerContent?: ReactNode;
|
||||
breadcrumbContext?: BreadcrumbContext;
|
||||
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
|
||||
/** Section key for URL building ('animations', 'browse') */
|
||||
sectionKey?: string | null;
|
||||
/** Pin the downloaded filter on and hide its switch (used by the Downloaded page) */
|
||||
forceDownloaded?: boolean;
|
||||
|
||||
@@ -29,7 +29,7 @@ interface GameCardProps {
|
||||
isFavoritePage?: boolean;
|
||||
shareToken?: string | null;
|
||||
breadcrumbContext?: BreadcrumbContext;
|
||||
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
|
||||
/** Section key for URL building ('animations', 'browse') */
|
||||
sectionKey?: string | null;
|
||||
/** Rating aggregate data for this game */
|
||||
ratingData?: BatchRatingAggregate;
|
||||
|
||||
@@ -12,7 +12,7 @@ interface GameGridProps {
|
||||
isFavoritePage?: boolean;
|
||||
shareToken?: string | null;
|
||||
breadcrumbContext?: BreadcrumbContext;
|
||||
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
|
||||
/** Section key for URL building ('animations', 'browse') */
|
||||
sectionKey?: string | null;
|
||||
/** Batch rating data keyed by game ID */
|
||||
ratingDataMap?: Record<string, BatchRatingAggregate>;
|
||||
|
||||
@@ -12,7 +12,7 @@ interface GameListProps {
|
||||
isFavoritePage?: boolean;
|
||||
shareToken?: string | null;
|
||||
breadcrumbContext?: BreadcrumbContext;
|
||||
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
|
||||
/** Section key for URL building ('animations', 'browse') */
|
||||
sectionKey?: string | null;
|
||||
/** Batch rating data keyed by game ID */
|
||||
ratingDataMap?: Record<string, BatchRatingAggregate>;
|
||||
|
||||
@@ -28,7 +28,7 @@ interface GameListItemProps {
|
||||
isFavoritePage?: boolean;
|
||||
shareToken?: string | null;
|
||||
breadcrumbContext?: BreadcrumbContext;
|
||||
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
|
||||
/** Section key for URL building ('animations', 'browse') */
|
||||
sectionKey?: string | null;
|
||||
/** Rating aggregate data for this game */
|
||||
ratingData?: BatchRatingAggregate;
|
||||
|
||||
@@ -12,9 +12,9 @@ export interface SectionIcon {
|
||||
* Section configuration for game routes
|
||||
*/
|
||||
export interface SectionConfig {
|
||||
/** URL prefix for game routes (e.g., '/flash') */
|
||||
/** URL prefix for game routes (e.g., '/browse') */
|
||||
prefix: string;
|
||||
/** Sidebar item path (e.g., '/flash') */
|
||||
/** Sidebar item path (e.g., '/browse') */
|
||||
sidebarPath: string;
|
||||
/** Label for breadcrumbs */
|
||||
breadcrumbLabel: string;
|
||||
@@ -28,20 +28,6 @@ export interface SectionConfig {
|
||||
* Section configurations keyed by section identifier
|
||||
*/
|
||||
export const SECTION_CONFIGS: Record<string, SectionConfig> = {
|
||||
flash: {
|
||||
prefix: '/flash',
|
||||
sidebarPath: '/flash',
|
||||
breadcrumbLabel: 'Flash Games',
|
||||
breadcrumbHref: '/flash',
|
||||
icon: { type: 'image', value: '/images/Flash.png' },
|
||||
},
|
||||
html5: {
|
||||
prefix: '/html5',
|
||||
sidebarPath: '/html5',
|
||||
breadcrumbLabel: 'HTML5 Games',
|
||||
breadcrumbHref: '/html5',
|
||||
icon: { type: 'image', value: '/images/HTML5.png' },
|
||||
},
|
||||
animations: {
|
||||
prefix: '/animations',
|
||||
sidebarPath: '/animations',
|
||||
@@ -65,8 +51,6 @@ export const SECTION_CONFIGS: Record<string, SectionConfig> = {
|
||||
* When breadcrumb context exists, it takes priority over prefix matching.
|
||||
*/
|
||||
export const SIDEBAR_PREFIX_MAP: Record<string, readonly string[]> = {
|
||||
'/flash': ['/flash/'],
|
||||
'/html5': ['/html5/'],
|
||||
'/animations': ['/animations/'],
|
||||
'/browse': ['/browse/', '/games/'], // /games/ only highlights Browse on direct URL access
|
||||
'/playlists': ['/playlists/'],
|
||||
@@ -76,7 +60,7 @@ export const SIDEBAR_PREFIX_MAP: Record<string, readonly string[]> = {
|
||||
|
||||
/**
|
||||
* Get section configuration from a URL pathname
|
||||
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
|
||||
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
|
||||
* @returns Section config if pathname matches a section route, null otherwise
|
||||
*/
|
||||
export function getSectionFromPath(pathname: string): SectionConfig | null {
|
||||
@@ -90,8 +74,8 @@ export function getSectionFromPath(pathname: string): SectionConfig | null {
|
||||
|
||||
/**
|
||||
* Get section key from a URL pathname
|
||||
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
|
||||
* @returns Section key ('flash', 'html5', etc.) or null
|
||||
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
|
||||
* @returns Section key ('browse', 'animations') or null
|
||||
*/
|
||||
export function getSectionKeyFromPath(pathname: string): string | null {
|
||||
for (const [key, config] of Object.entries(SECTION_CONFIGS)) {
|
||||
@@ -105,9 +89,9 @@ export function getSectionKeyFromPath(pathname: string): string | null {
|
||||
/**
|
||||
* Build a section-based game URL
|
||||
* @param gameId - Game UUID
|
||||
* @param sectionKey - Section key ('flash', 'html5', 'animations', 'browse') or null
|
||||
* @param sectionKey - Section key ('animations', 'browse') or null
|
||||
* @param isPlay - Whether this is a play URL (adds '/play' suffix)
|
||||
* @returns URL string (e.g., '/flash/abc-123' or '/flash/abc-123/play')
|
||||
* @returns URL string (e.g., '/browse/abc-123' or '/browse/abc-123/play')
|
||||
*/
|
||||
export function buildSectionGameUrl(
|
||||
gameId: string,
|
||||
@@ -127,7 +111,7 @@ export function buildSectionGameUrl(
|
||||
|
||||
/**
|
||||
* Derive breadcrumb context from a URL pathname
|
||||
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
|
||||
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
|
||||
* @returns BreadcrumbContext if pathname matches a section route, null otherwise
|
||||
*/
|
||||
export function getBreadcrumbContextFromPath(pathname: string): BreadcrumbContext | null {
|
||||
|
||||
@@ -154,7 +154,6 @@ export const handlers = [
|
||||
app: {
|
||||
maintenance_mode: false,
|
||||
allow_registration: true,
|
||||
homeRecentHours: 24,
|
||||
},
|
||||
metadata: {
|
||||
flashpointEdition: 'infinity',
|
||||
|
||||
@@ -7,7 +7,6 @@ export interface AppSettings {
|
||||
maintenanceMessage: string;
|
||||
defaultTheme?: string;
|
||||
defaultPrimaryColor?: string;
|
||||
homeRecentHours?: number;
|
||||
}
|
||||
|
||||
export interface FeatureSettings {
|
||||
@@ -71,10 +70,7 @@ export interface Domain {
|
||||
}
|
||||
|
||||
export interface PublicSettings {
|
||||
app: Pick<
|
||||
AppSettings,
|
||||
'siteName' | 'tagline' | 'maintenanceMode' | 'maintenanceMessage' | 'homeRecentHours'
|
||||
>;
|
||||
app: Pick<AppSettings, 'siteName' | 'tagline' | 'maintenanceMode' | 'maintenanceMessage'>;
|
||||
auth: Pick<AuthSettings, 'guestAccessEnabled' | 'userRegistrationEnabled'>;
|
||||
features?: Partial<FeatureSettings>;
|
||||
metadata?: {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { GameBrowseLayout } from '@/components/library/GameBrowseLayout';
|
||||
|
||||
export function FlashGamesView() {
|
||||
return (
|
||||
<GameBrowseLayout
|
||||
title="Flash Games"
|
||||
library="arcade"
|
||||
platform="Flash"
|
||||
breadcrumbContext={{ label: 'Flash Games', href: '/flash' }}
|
||||
sectionKey="flash"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { GameBrowseLayout } from '@/components/library/GameBrowseLayout';
|
||||
|
||||
export function HTML5GamesView() {
|
||||
return (
|
||||
<GameBrowseLayout
|
||||
title="HTML5 Games"
|
||||
library="arcade"
|
||||
platform="HTML5"
|
||||
breadcrumbContext={{ label: 'HTML5 Games', href: '/html5' }}
|
||||
sectionKey="html5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user