Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2025-05-14 - [Form Accessibility & Deletion Safety]
**Learning:** In a complex SPA like Pascal, ensuring form controls have unique IDs (using entity IDs as suffixes) is critical for accessibility when navigating between detail views. Additionally, adding simple `window.confirm` checks to destructive actions like game deletion is a high-impact, low-effort micro-UX improvement that prevents accidental data loss.
**Action:** Always verify that every input/select/textarea has a corresponding `<label htmlFor="...">` with a unique ID, and always wrap destructive actions in a confirmation dialog.
20 changes: 14 additions & 6 deletions src/components/Library/GameDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ export function GameDetail({ gameId, onBack, onFilter }: GameDetailProps) {
setGame({ ...game, notes: notesValue });
};

const handleDelete = async () => { if (await deleteGame(game.id)) onBack(); };
const handleDelete = async () => {
if (window.confirm(t("confirmDelete"))) {
if (await deleteGame(game.id)) onBack();
}
};

const handlePlayTimeChange = async (hours: number) => {
if (await updatePlayTime(game.id, hours)) {
Expand Down Expand Up @@ -158,9 +162,10 @@ export function GameDetail({ gameId, onBack, onFilter }: GameDetailProps) {
<div className="pt-4 theme-border border-t mt-6 space-y-4">
{/* Launch Path / Executable */}
<div>
<label className="block text-sm font-medium theme-text-secondary mb-1">{t('executablePath')}</label>
<label htmlFor={`executable-path-${game.id}`} className="block text-sm font-medium theme-text-secondary mb-1">{t('executablePath')}</label>
<div className="flex gap-2">
<input
id={`executable-path-${game.id}`}
type="text"
value={game.executable_path || ''}
readOnly
Expand All @@ -180,6 +185,7 @@ export function GameDetail({ gameId, onBack, onFilter }: GameDetailProps) {
onClick={handleClearExecutable}
className="px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
title="Clear executable path"
aria-label="Clear executable path"
>
</button>
Expand All @@ -198,8 +204,9 @@ export function GameDetail({ gameId, onBack, onFilter }: GameDetailProps) {

{/* Completion Status */}
<div>
<label className="block text-sm font-medium theme-text-secondary mb-1">{t('completionStatus')}</label>
<label htmlFor={`completion-status-${game.id}`} className="block text-sm font-medium theme-text-secondary mb-1">{t('completionStatus')}</label>
<select
id={`completion-status-${game.id}`}
value={game.completion_status || 'playing'}
onChange={(e) => handleStatusChange(e.target.value)}
className="w-full px-3 py-2 theme-bg-tertiary theme-border border rounded-lg theme-text-primary focus:ring-2 focus:ring-indigo-500"
Expand All @@ -213,8 +220,9 @@ export function GameDetail({ gameId, onBack, onFilter }: GameDetailProps) {

{/* Play Time */}
<div>
<label className="block text-sm font-medium theme-text-secondary mb-1">{t('playTime')} ({t('hours')})</label>
<label htmlFor={`play-time-${game.id}`} className="block text-sm font-medium theme-text-secondary mb-1">{t('playTime')} ({t('hours')})</label>
<input
id={`play-time-${game.id}`}
type="number"
min="0"
step="0.1"
Expand All @@ -224,8 +232,8 @@ export function GameDetail({ gameId, onBack, onFilter }: GameDetailProps) {
/>
</div>
<div>
<label className="block text-sm font-medium theme-text-secondary mb-1">{t('notes')}</label>
<textarea value={notesValue} onChange={(e) => setNotesValue(e.target.value)}
<label htmlFor={`notes-${game.id}`} className="block text-sm font-medium theme-text-secondary mb-1">{t('notes')}</label>
<textarea id={`notes-${game.id}`} value={notesValue} onChange={(e) => setNotesValue(e.target.value)}
onBlur={handleSaveNotes} rows={4}
className="w-full px-3 py-2 theme-bg-tertiary theme-border border rounded-lg theme-text-primary focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder={t('addNotes')} />
Expand Down
7 changes: 5 additions & 2 deletions src/components/Library/GameDetailHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export function GameDetailHeader({ game, onGameUpdated, onPlatformChange, onFilt
onClick={(e) => { e.stopPropagation(); onFavoriteToggle(); }}
className="absolute -top-2 -right-2 w-10 h-10 flex items-center justify-center text-3xl transition-transform hover:scale-110"
title={game.is_favorite ? t('removeFromFavorites') : t('addToFavorites')}
aria-label={game.is_favorite ? t('removeFromFavorites') : t('addToFavorites')}
>
{game.is_favorite ? (
<span className="text-yellow-400 drop-shadow-lg">★</span>
Expand All @@ -161,10 +162,11 @@ export function GameDetailHeader({ game, onGameUpdated, onPlatformChange, onFilt
{/* Platform Selector */}
{onPlatformChange && (
<div>
<label className="block text-xs font-medium theme-text-secondary mb-1">
<label htmlFor={`platform-select-${game.id}`} className="block text-xs font-medium theme-text-secondary mb-1">
{t('platforms') || 'Plateformes'}
</label>
<select
id={`platform-select-${game.id}`}
value={game.platform || ''}
onChange={handlePlatformSelect}
className="w-full px-2 py-1.5 text-sm theme-bg-tertiary theme-border border rounded-lg theme-text-primary focus:ring-2 focus:ring-indigo-500"
Expand Down Expand Up @@ -364,11 +366,12 @@ export function GameDetailHeader({ game, onGameUpdated, onPlatformChange, onFilt
{/* Personal Rating */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="theme-text-muted text-sm">{t('personalRating')} (0-100): <span className="text-purple-500 font-semibold">{game.personal_rating !== null && game.personal_rating !== undefined ? `${game.personal_rating}/100` : '-'}</span></span>
<label htmlFor={`personal-rating-${game.id}`} className="theme-text-muted text-sm cursor-pointer">{t('personalRating')} (0-100): <span className="text-purple-500 font-semibold">{game.personal_rating !== null && game.personal_rating !== undefined ? `${game.personal_rating}/100` : '-'}</span></label>
</div>
<div className="flex items-center gap-3">
<span className="text-xs theme-text-muted">0</span>
<input
id={`personal-rating-${game.id}`}
type="range"
min="0"
max="100"
Expand Down
2 changes: 0 additions & 2 deletions src/components/Library/GameScreenshotsCarousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export function GameScreenshotsCarousel({ gameId }: GameScreenshotsCarouselProps
const [igdbScreenshots, setIgdbScreenshots] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [hasIgdbId, setHasIgdbId] = useState(false);

useEffect(() => {
const loadScreenshots = async () => {
Expand All @@ -29,7 +28,6 @@ export function GameScreenshotsCarousel({ gameId }: GameScreenshotsCarouselProps
// Load game data to check IGDB ID
try {
const game = await invoke<{ igdb_id: number | null }>("get_game_by_id", { id: gameId });
setHasIgdbId(!!game?.igdb_id);

// Load IGDB screenshots if available
if (game?.igdb_id) {
Expand Down
1 change: 1 addition & 0 deletions src/components/Library/TagEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export function TagEditor({ gameId, tags, onTagsChanged }: TagEditorProps) {
onClick={() => handleRemoveTag(tag.id)}
className="ml-1 text-white hover:text-gray-200"
aria-label={`Remove tag ${tag.name}`}
title={`Remove tag ${tag.name}`}
>
×
</button>
Expand Down