feat: tabbed About dialog with keyboard shortcuts reference
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,12 +4,43 @@
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
let version = $state('');
|
||||
let activeTab = $state<'about' | 'shortcuts'>('about');
|
||||
|
||||
$effect(() => {
|
||||
getVersion().then((v) => {
|
||||
version = v;
|
||||
});
|
||||
});
|
||||
|
||||
const globalShortcuts = [
|
||||
{ key: 'Space', action: 'Play / Pause' },
|
||||
{ key: 'K', action: 'Play / Pause' },
|
||||
{ key: 'J', action: 'Shuttle slower (−0.25×)' },
|
||||
{ key: 'L', action: 'Shuttle faster (+0.25×)' },
|
||||
{ key: '←', action: 'Seek back 5s' },
|
||||
{ key: '→', action: 'Seek forward 5s' },
|
||||
{ key: 'Shift+←', action: 'Seek back 1s' },
|
||||
{ key: 'Shift+→', action: 'Seek forward 1s' },
|
||||
{ key: ',', action: 'Previous frame' },
|
||||
{ key: '.', action: 'Next frame' },
|
||||
{ key: '< (Shift+,)', action: 'Previous keyframe' },
|
||||
{ key: '> (Shift+.)', action: 'Next keyframe' },
|
||||
{ key: 'C / P', action: 'Toggle mode' },
|
||||
{ key: '⌘/', action: 'About' },
|
||||
];
|
||||
|
||||
const playerShortcuts = [
|
||||
{ key: 'F', action: 'Toggle fullscreen' },
|
||||
{ key: 'X', action: 'Cut clip at playhead → Clipper' },
|
||||
{ key: 'Z', action: 'Cut clip 10s before playhead → Clipper' },
|
||||
];
|
||||
|
||||
const clipperShortcuts = [
|
||||
{ key: 'I', action: 'Set in-point' },
|
||||
{ key: 'O', action: 'Set out-point' },
|
||||
{ key: 'Delete / ⌫', action: 'Remove selected clip' },
|
||||
{ key: '⌘E', action: 'Export' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -26,62 +57,134 @@
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
class:wide={activeTab === 'shortcuts'}
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
|
||||
<stop offset="0%" stop-color="#fb923c"/>
|
||||
<stop offset="100%" stop-color="#ea580c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="90" fill="#18181b"/>
|
||||
<g transform="translate(-3, 3)">
|
||||
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
|
||||
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
</g>
|
||||
<g transform="translate(3, -3)">
|
||||
<polygon points="273,220 440,220 440,292 247,292" fill="#3f3f46"/>
|
||||
<rect x="298" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="345" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="392" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="278" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="330" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="382" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
</g>
|
||||
<line x1="269" y1="216" x2="243" y2="296" stroke="#f97316" stroke-width="2" opacity="0.6"/>
|
||||
<g transform="rotate(20, 256, 256)">
|
||||
<polygon points="264,250 172,155 248,262" fill="url(#blade-grad)"/>
|
||||
<polygon points="248,250 340,155 264,262" fill="url(#blade-grad)"/>
|
||||
<polygon points="248,250 194,330 202,336 264,262" fill="#52525b"/>
|
||||
<polygon points="264,250 318,330 310,336 248,262" fill="#52525b"/>
|
||||
<circle cx="192" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
||||
<circle cx="320" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
||||
<circle cx="256" cy="256" r="7" fill="#a1a1aa"/>
|
||||
</g>
|
||||
</svg>
|
||||
<h2>GUI Video Clipper</h2>
|
||||
{#if version}
|
||||
<span class="version">v{version}</span>
|
||||
<!-- Tab bar -->
|
||||
<div class="tab-bar" role="tablist">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'about'}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'about'}
|
||||
onclick={() => { activeTab = 'about'; }}
|
||||
type="button"
|
||||
>About</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'shortcuts'}
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'shortcuts'}
|
||||
onclick={() => { activeTab = 'shortcuts'; }}
|
||||
type="button"
|
||||
>Shortcuts</button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'about'}
|
||||
<!-- About content -->
|
||||
<div class="about-content">
|
||||
<svg class="app-icon" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="blade-grad" gradientUnits="userSpaceOnUse" x1="256" y1="130" x2="256" y2="290">
|
||||
<stop offset="0%" stop-color="#fb923c"/>
|
||||
<stop offset="100%" stop-color="#ea580c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="90" fill="#18181b"/>
|
||||
<g transform="translate(-3, 3)">
|
||||
<polygon points="72,220 265,220 239,292 72,292" fill="#3f3f46"/>
|
||||
<rect x="92" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="130" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="168" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="92" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="130" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="168" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
</g>
|
||||
<g transform="translate(3, -3)">
|
||||
<polygon points="273,220 440,220 440,292 247,292" fill="#3f3f46"/>
|
||||
<rect x="298" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="345" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="392" y="226" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="278" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="330" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
<rect x="382" y="273" width="11" height="13" rx="3" fill="#27272a"/>
|
||||
</g>
|
||||
<line x1="269" y1="216" x2="243" y2="296" stroke="#f97316" stroke-width="2" opacity="0.6"/>
|
||||
<g transform="rotate(20, 256, 256)">
|
||||
<polygon points="264,250 172,155 248,262" fill="url(#blade-grad)"/>
|
||||
<polygon points="248,250 340,155 264,262" fill="url(#blade-grad)"/>
|
||||
<polygon points="248,250 194,330 202,336 264,262" fill="#52525b"/>
|
||||
<polygon points="264,250 318,330 310,336 248,262" fill="#52525b"/>
|
||||
<circle cx="192" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
||||
<circle cx="320" cy="348" r="20" stroke="#a1a1aa" stroke-width="4" fill="#18181b"/>
|
||||
<circle cx="256" cy="256" r="7" fill="#a1a1aa"/>
|
||||
</g>
|
||||
</svg>
|
||||
<h2>GUI Video Clipper</h2>
|
||||
{#if version}
|
||||
<span class="version">v{version}</span>
|
||||
{/if}
|
||||
<p class="author">by cottongin</p>
|
||||
<p class="license">MIT License</p>
|
||||
<a
|
||||
class="repo-link"
|
||||
href="https://code.cottongin.xyz/cottongin/gui-video-clipper"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
code.cottongin.xyz/cottongin/gui-video-clipper
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Shortcuts content -->
|
||||
<div class="shortcuts-content">
|
||||
<section>
|
||||
<h3>Global</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each globalShortcuts as { key, action }}
|
||||
<tr>
|
||||
<td class="key-cell"><kbd>{key}</kbd></td>
|
||||
<td class="action-cell">{action}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Player Mode</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each playerShortcuts as { key, action }}
|
||||
<tr>
|
||||
<td class="key-cell"><kbd>{key}</kbd></td>
|
||||
<td class="action-cell">{action}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Clipper Mode</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each clipperShortcuts as { key, action }}
|
||||
<tr>
|
||||
<td class="key-cell"><kbd>{key}</kbd></td>
|
||||
<td class="action-cell">{action}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="author">by cottongin</p>
|
||||
<p class="license">MIT License</p>
|
||||
<a
|
||||
class="repo-link"
|
||||
href="https://code.cottongin.xyz/cottongin/gui-video-clipper"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
code.cottongin.xyz/cottongin/gui-video-clipper
|
||||
</a>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={onClose}>Close</button>
|
||||
</div>
|
||||
@@ -103,7 +206,7 @@
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
padding: 24px 32px 32px;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
@@ -111,6 +214,53 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: max-width 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog.wide {
|
||||
max-width: 520px;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
/* --- Tabs --- */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
background: none;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* --- About content --- */
|
||||
.about-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
@@ -153,6 +303,64 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* --- Shortcuts content --- */
|
||||
.shortcuts-content {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
overflow-y: auto;
|
||||
max-height: calc(70vh - 140px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.shortcuts-content h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.shortcuts-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.shortcuts-content tr {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.shortcuts-content tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.key-cell {
|
||||
width: 40%;
|
||||
padding: 5px 8px 5px 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
padding: 5px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* --- Actions --- */
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
59
tests/lib/components/AboutDialog.test.ts
Normal file
59
tests/lib/components/AboutDialog.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, fireEvent } from '@testing-library/svelte';
|
||||
import AboutDialog from '$lib/components/AboutDialog.svelte';
|
||||
|
||||
vi.mock('@tauri-apps/api/app', () => ({
|
||||
getVersion: vi.fn().mockResolvedValue('0.2.1'),
|
||||
}));
|
||||
|
||||
describe('AboutDialog', () => {
|
||||
it('renders the About tab by default', () => {
|
||||
const { getByText } = render(AboutDialog, {
|
||||
props: { onClose: vi.fn() },
|
||||
});
|
||||
expect(getByText('GUI Video Clipper')).toBeTruthy();
|
||||
expect(getByText('by cottongin')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows two tabs: About and Shortcuts', () => {
|
||||
const { getByRole } = render(AboutDialog, {
|
||||
props: { onClose: vi.fn() },
|
||||
});
|
||||
const tablist = getByRole('tablist');
|
||||
expect(tablist).toBeTruthy();
|
||||
expect(tablist.querySelectorAll('[role="tab"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('switches to Shortcuts tab on click', async () => {
|
||||
const { getByText, getAllByText } = render(AboutDialog, {
|
||||
props: { onClose: vi.fn() },
|
||||
});
|
||||
const shortcutsTab = getByText('Shortcuts');
|
||||
await fireEvent.click(shortcutsTab);
|
||||
|
||||
expect(getAllByText('Play / Pause').length).toBeGreaterThan(0);
|
||||
expect(getByText('Toggle mode')).toBeTruthy();
|
||||
expect(getByText('Player Mode')).toBeTruthy();
|
||||
expect(getByText('Clipper Mode')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('switches back to About tab', async () => {
|
||||
const { getByText, getByRole } = render(AboutDialog, {
|
||||
props: { onClose: vi.fn() },
|
||||
});
|
||||
await fireEvent.click(getByRole('tab', { name: 'Shortcuts' }));
|
||||
await fireEvent.click(getByRole('tab', { name: 'About' }));
|
||||
|
||||
expect(getByText('GUI Video Clipper')).toBeTruthy();
|
||||
expect(getByText('by cottongin')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onClose when Close button is clicked', async () => {
|
||||
const onClose = vi.fn();
|
||||
const { getByText } = render(AboutDialog, {
|
||||
props: { onClose },
|
||||
});
|
||||
await fireEvent.click(getByText('Close'));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user