First working POC

This commit is contained in:
2026-01-26 22:21:54 +01:00
commit cfddc1edea
255 changed files with 77606 additions and 0 deletions
@@ -0,0 +1,33 @@
<script lang="ts">
/**
* BackButton component - Reusable back navigation button
*
* @req: UR-007 - Navigate media in library
* @req: DR-007 - Library browsing screens (navigation)
*/
interface Props {
onClick: () => void;
label?: string;
size?: "sm" | "md" | "lg";
className?: string;
}
let { onClick, label = "Back", size = "md", className = "" }: Props = $props();
const sizeMap = {
sm: "w-5 h-5",
md: "w-6 h-6",
lg: "w-8 h-8",
};
</script>
<button
onclick={onClick}
aria-label={label}
class={`text-gray-400 hover:text-white transition-colors ${className}`}
>
<svg class={`${sizeMap[size]} fill-none stroke-current`} stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
@@ -0,0 +1,87 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { auth } from "$lib/stores/auth";
import { get } from "svelte/store";
interface Props {
itemId: string;
imageType?: string;
tag?: string;
maxWidth?: number;
maxHeight?: number;
class?: string;
alt?: string;
}
let {
itemId,
imageType = "Primary",
tag,
maxWidth,
maxHeight,
class: className = "",
alt = "",
}: Props = $props();
let imageUrl = $state<string | null>(null);
let loading = $state(true);
let error = $state(false);
async function loadImage() {
if (!itemId) {
loading = false;
return;
}
try {
loading = true;
error = false;
// Get repository handle from auth store
const authState = get(auth);
if (!authState.isAuthenticated) {
throw new Error("Not authenticated");
}
const repository = auth.getRepository();
const repositoryHandle = repository.getHandle();
// Call Rust to get image as base64 data URL
const dataUrl = await invoke<string>("image_get_url", {
repositoryHandle,
request: {
itemId,
imageType,
maxWidth,
maxHeight,
tag,
},
});
// Use data URL directly
imageUrl = dataUrl;
error = false;
} catch (e) {
console.error(`Failed to load image ${itemId}:`, e);
error = true;
imageUrl = null;
} finally {
loading = false;
}
}
// Reload image when props change
$effect(() => {
imageUrl = null;
loadImage();
});
</script>
{#if loading}
<div class="{className} bg-gray-700 animate-pulse" aria-busy="true" aria-label="Loading image"></div>
{:else if error}
<div class="{className} bg-gray-800 flex items-center justify-center">
<span class="text-gray-500 text-xs">Failed to load</span>
</div>
{:else if imageUrl}
<img src={imageUrl} {alt} class={className} />
{/if}
@@ -0,0 +1,38 @@
<script lang="ts">
/**
* ResultsCounter component - Shows item count with optional search context
*
* @req: UR-007 - Navigate media in library
* @req: DR-007 - Library browsing screens
*/
interface Props {
count: number;
itemType: string; // "genre", "album", "track", "artist", "movie", "show", etc.
searchQuery?: string;
className?: string;
}
let { count, itemType, searchQuery = "", className = "" }: Props = $props();
const itemTypeLabels: Record<string, { singular: string; plural: string }> = {
genre: { singular: "genre", plural: "genres" },
album: { singular: "album", plural: "albums" },
track: { singular: "track", plural: "tracks" },
artist: { singular: "artist", plural: "artists" },
movie: { singular: "movie", plural: "movies" },
show: { singular: "show", plural: "shows" },
playlist: { singular: "playlist", plural: "playlists" },
};
const labels = itemTypeLabels[itemType] || { singular: itemType, plural: `${itemType}s` };
const label = count === 1 ? labels.singular : labels.plural;
</script>
<p class={`text-sm text-gray-400 ${className}`}>
{count}
{label}
{#if searchQuery}
matching "{searchQuery}"
{/if}
</p>
@@ -0,0 +1,48 @@
<script lang="ts">
/**
* SearchBar component - Reusable search input with icon
*
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: UR-030 - Quick genre browsing and filtering
* @req: DR-007 - Library browsing screens (search component)
* @req: DR-011 - Search bar with cross-library search
*/
interface Props {
value: string;
placeholder?: string;
onInput: (value: string) => void;
className?: string;
}
let { value, placeholder = "Search...", onInput, className = "" }: Props = $props();
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
onInput(target.value);
}
</script>
<div class={`relative ${className}`}>
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
{placeholder}
{value}
oninput={handleInput}
class="w-full pl-10 pr-4 py-3 bg-[var(--color-surface)] border border-gray-700 rounded-lg focus:outline-none focus:border-[var(--color-jellyfin)] text-white placeholder-gray-500"
/>
</div>
+240
View File
@@ -0,0 +1,240 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/svelte";
import SearchBar from "./SearchBar.svelte";
describe("SearchBar", () => {
describe("Rendering Tests", () => {
it("should render input field with placeholder", () => {
render(SearchBar, {
props: {
value: "",
placeholder: "Search test...",
onInput: vi.fn(),
},
});
const input = screen.getByPlaceholderText("Search test...");
expect(input).toBeTruthy();
});
it("should render search icon", () => {
const { container } = render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput: vi.fn(),
},
});
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
const classString = svg?.getAttribute("class") || "";
expect(classString).toContain("w-5");
expect(classString).toContain("h-5");
});
it("should display current value in input", () => {
render(SearchBar, {
props: {
value: "test query",
placeholder: "Search...",
onInput: vi.fn(),
},
});
const input = screen.getByDisplayValue("test query") as HTMLInputElement;
expect(input.value).toBe("test query");
});
it("should apply custom className", () => {
const { container } = render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput: vi.fn(),
className: "custom-class",
},
});
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("custom-class");
});
it("should have proper accessibility attributes", () => {
render(SearchBar, {
props: {
value: "",
placeholder: "Search genres...",
onInput: vi.fn(),
},
});
const input = screen.getByPlaceholderText("Search genres...") as HTMLInputElement;
expect(input.type).toBe("text");
});
});
describe("Interaction Tests", () => {
it("should call onInput callback when user types", () => {
const onInput = vi.fn();
render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput,
},
});
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
fireEvent.input(input, { target: { value: "test" } });
expect(onInput).toHaveBeenCalled();
});
it("should pass correct value to onInput callback", () => {
const onInput = vi.fn();
render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput,
},
});
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
fireEvent.input(input, { target: { value: "album search" } });
// Check that callback was called with the typed value
expect(onInput).toHaveBeenCalledWith("album search");
});
it("should handle multiple input changes", () => {
const onInput = vi.fn();
render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput,
},
});
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
fireEvent.input(input, { target: { value: "test" } });
fireEvent.input(input, { target: { value: "testing" } });
expect(onInput).toHaveBeenCalled();
});
});
describe("Edge Cases", () => {
it("should handle empty search query", () => {
render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput: vi.fn(),
},
});
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
expect(input.value).toBe("");
});
it("should handle special characters in value", () => {
render(SearchBar, {
props: {
value: '@$%^&*()',
placeholder: "Search...",
onInput: vi.fn(),
},
});
const input = screen.getByDisplayValue("@$%^&*()") as HTMLInputElement;
expect(input.value).toBe("@$%^&*()");
});
it("should handle very long input values", () => {
const longValue = "a".repeat(500);
render(SearchBar, {
props: {
value: longValue,
placeholder: "Search...",
onInput: vi.fn(),
},
});
const input = screen.getByDisplayValue(longValue) as HTMLInputElement;
expect(input.value).toBe(longValue);
});
it("should work with numeric input", () => {
const onInput = vi.fn();
render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput,
},
});
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
fireEvent.input(input, { target: { value: "12345" } });
expect(onInput).toHaveBeenCalledWith("12345");
});
});
describe("Requirement Tests", () => {
it("should support searching with spaces", () => {
const onInput = vi.fn();
render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput,
},
});
const input = screen.getByPlaceholderText("Search...") as HTMLInputElement;
fireEvent.input(input, { target: { value: "search multiple words" } });
expect(onInput).toHaveBeenCalledWith("search multiple words");
});
it("should work as controlled component with value prop", () => {
render(SearchBar, {
props: {
value: "initial value",
placeholder: "Search...",
onInput: vi.fn(),
},
});
const input = screen.getByDisplayValue("initial value") as HTMLInputElement;
expect(input.value).toBe("initial value");
});
it("should have proper styling for dark theme", () => {
const { container } = render(SearchBar, {
props: {
value: "",
placeholder: "Search...",
onInput: vi.fn(),
},
});
const input = container.querySelector("input");
expect(input).toBeTruthy();
const classString = input?.getAttribute("class") || "";
expect(classString.length).toBeGreaterThan(0);
expect(classString).toContain("bg-");
expect(classString).toContain("text-white");
expect(classString).toContain("placeholder-gray");
});
});
});
@@ -0,0 +1,58 @@
<script lang="ts">
/**
* SortButtonGroup component - Button group for sorting options
*
* @req: UR-007 - Navigate media in library
* @req: DR-007 - Library browsing screens
*/
export interface SortOption {
key: string;
label: string;
}
interface Props {
options: SortOption[];
selected: string;
onSelect: (key: string) => void;
className?: string;
}
let { options, selected, onSelect, className = "" }: Props = $props();
function handleClick(key: string) {
onSelect(key);
}
function handleKeydown(e: KeyboardEvent, index: number) {
if (e.key === "ArrowRight" && index < options.length - 1) {
e.preventDefault();
onSelect(options[index + 1].key);
} else if (e.key === "ArrowLeft" && index > 0) {
e.preventDefault();
onSelect(options[index - 1].key);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(options[index].key);
}
}
</script>
<div class={`flex flex-wrap gap-2 ${className}`}>
{#each options as option, index (option.key)}
<button
onclick={() => handleClick(option.key)}
onkeydown={(e) => handleKeydown(e, index)}
role="radio"
aria-checked={selected === option.key}
tabindex={selected === option.key ? 0 : -1}
class={`px-4 py-3 rounded-lg font-medium transition-colors ${
selected === option.key
? "bg-[var(--color-jellyfin)] text-white"
: "bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)]"
}`}
>
{option.label}
</button>
{/each}
</div>