phase 22
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 12s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 9s
CI/CD Pipeline / VM Test - performance (push) Successful in 9s
CI/CD Pipeline / VM Test - security (push) Successful in 9s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 24s
CI/CD Pipeline / Nix Flake Check (push) Successful in 53s
CI/CD Pipeline / CI Summary (push) Successful in 1s
CI/CD Pipeline / VM Test - backend-integration (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - full-stack (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - performance (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - security (pull_request) Successful in 2s
CI/CD Pipeline / Backend Linting (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Linting (pull_request) Successful in 16s
CI/CD Pipeline / Nix Flake Check (pull_request) Successful in 38s
CI/CD Pipeline / CI Summary (pull_request) Successful in 0s

This commit is contained in:
Danilo Reyes
2025-11-02 15:50:30 -06:00
parent d4fbdf9273
commit ce353f8b49
23 changed files with 2524 additions and 103 deletions

View File

@@ -0,0 +1,92 @@
/**
* Image library API client.
*/
import { apiClient } from './client';
export interface LibraryImage {
id: string;
filename: string;
file_size: number;
mime_type: string;
width: number;
height: number;
reference_count: number;
created_at: string;
thumbnail_url: string | null;
}
export interface LibraryListResponse {
images: LibraryImage[];
total: number;
limit: number;
offset: number;
}
export interface LibraryStats {
total_images: number;
total_size_bytes: number;
total_board_references: number;
average_references_per_image: number;
}
export interface AddToBoardRequest {
board_id: string;
position?: { x: number; y: number };
}
/**
* List images in user's library.
*
* @param query - Optional search query
* @param limit - Results per page
* @param offset - Pagination offset
* @returns Library image list with pagination info
*/
export async function listLibraryImages(
query?: string,
limit: number = 50,
offset: number = 0
): Promise<LibraryListResponse> {
let url = `/library/images?limit=${limit}&offset=${offset}`;
if (query) {
url += `&query=${encodeURIComponent(query)}`;
}
return apiClient.get<LibraryListResponse>(url);
}
/**
* Add a library image to a board.
*
* @param imageId - Image UUID
* @param request - Add to board request data
* @returns Response with new board image ID
*/
export async function addImageToBoard(
imageId: string,
request: AddToBoardRequest
): Promise<{ id: string; message: string }> {
return apiClient.post<{ id: string; message: string }>(
`/library/images/${imageId}/add-to-board`,
request
);
}
/**
* Permanently delete an image from library.
* This removes it from all boards and deletes the file.
*
* @param imageId - Image UUID
*/
export async function deleteLibraryImage(imageId: string): Promise<void> {
return apiClient.delete<void>(`/library/images/${imageId}`);
}
/**
* Get library statistics.
*
* @returns Library statistics
*/
export async function getLibraryStats(): Promise<LibraryStats> {
return apiClient.get<LibraryStats>('/library/stats');
}

View File

@@ -8,9 +8,12 @@
import { isImageSelected } from '$lib/stores/selection';
import { setupImageDrag } from './interactions/drag';
import { setupImageSelection } from './interactions/select';
import { activeQuality } from '$lib/stores/quality';
import { getAdaptiveThumbnailUrl } from '$lib/utils/adaptive-quality';
// Props
export let id: string; // Board image ID
export let imageId: string; // Image UUID for quality-based loading
export let imageUrl: string;
export let x: number = 0;
export let y: number = 0;
@@ -33,10 +36,21 @@
let cleanupDrag: (() => void) | null = null;
let cleanupSelection: (() => void) | null = null;
let unsubscribeSelection: (() => void) | null = null;
let isFullResolution: boolean = false;
// Subscribe to selection state for this image
$: isSelected = isImageSelected(id);
// Subscribe to quality changes
$: {
if (imageId && !isFullResolution) {
const newUrl = getAdaptiveThumbnailUrl(imageId);
if (imageObj && imageObj.src !== newUrl) {
loadImageWithQuality($activeQuality);
}
}
}
onMount(() => {
if (!layer) return;
@@ -198,6 +212,38 @@
export function getImageNode(): Konva.Image | null {
return imageNode;
}
/**
* Load image with specific quality level.
*/
function loadImageWithQuality(_quality: string) {
if (!imageId || !imageObj) return;
const qualityUrl = getAdaptiveThumbnailUrl(imageId);
if (imageObj.src !== qualityUrl) {
imageObj.src = qualityUrl;
}
}
/**
* Load full-resolution version on demand.
* Useful for zooming in or detailed viewing.
*/
export function loadFullResolution() {
if (!imageId || !imageObj || isFullResolution) return;
const fullResUrl = `/api/v1/images/${imageId}/original`;
imageObj.src = fullResUrl;
isFullResolution = true;
}
/**
* Check if currently showing full resolution.
*/
export function isShowingFullResolution(): boolean {
return isFullResolution;
}
</script>
<!-- This component doesn't render any DOM, it only manages Konva nodes -->

View File

@@ -0,0 +1,64 @@
/**
* Optimal layout algorithm for images.
*/
import type { ArrangedPosition, ImageForArrange } from './sort-name';
/**
* Arrange images with optimal packing algorithm.
* Uses a simple bin-packing approach.
*/
export function arrangeOptimal(
images: ImageForArrange[],
gridSpacing: number = 20,
startX: number = 0,
startY: number = 0
): ArrangedPosition[] {
if (images.length === 0) return [];
// Sort by area (largest first) for better packing
const sorted = [...images].sort((a, b) => b.width * b.height - a.width * a.height);
const positions: ArrangedPosition[] = [];
const placedRects: Array<{
x: number;
y: number;
width: number;
height: number;
}> = [];
// Calculate target width (similar to square root layout)
const totalArea = sorted.reduce((sum, img) => sum + img.width * img.height, 0);
const targetWidth = Math.sqrt(totalArea) * 1.5;
let currentX = startX;
let currentY = startY;
let rowHeight = 0;
for (const img of sorted) {
// Check if we need to wrap to next row
if (currentX > startX && currentX + img.width > startX + targetWidth) {
currentX = startX;
currentY += rowHeight + gridSpacing;
rowHeight = 0;
}
positions.push({
id: img.id,
x: currentX,
y: currentY,
});
placedRects.push({
x: currentX,
y: currentY,
width: img.width,
height: img.height,
});
currentX += img.width + gridSpacing;
rowHeight = Math.max(rowHeight, img.height);
}
return positions;
}

View File

@@ -0,0 +1,35 @@
/**
* Random arrangement of images.
*/
import type { ArrangedPosition, ImageForArrange } from './sort-name';
/**
* Arrange images randomly within a bounded area.
*/
export function arrangeRandom(
images: ImageForArrange[],
areaWidth: number = 2000,
areaHeight: number = 2000,
startX: number = 0,
startY: number = 0
): ArrangedPosition[] {
const positions: ArrangedPosition[] = [];
for (const img of images) {
// Random position within bounds, accounting for image size
const maxX = areaWidth - img.width;
const maxY = areaHeight - img.height;
const x = startX + Math.random() * Math.max(maxX, 0);
const y = startY + Math.random() * Math.max(maxY, 0);
positions.push({
id: img.id,
x: Math.round(x),
y: Math.round(y),
});
}
return positions;
}

View File

@@ -0,0 +1,44 @@
/**
* Sort images by upload date.
*/
import type { ArrangedPosition, ImageForArrange } from './sort-name';
export interface ImageWithDate extends ImageForArrange {
created_at: string;
}
/**
* Arrange images by upload date (oldest to newest).
*/
export function arrangeByDate(
images: ImageWithDate[],
gridSpacing: number = 20,
startX: number = 0,
startY: number = 0
): ArrangedPosition[] {
// Sort by date
const sorted = [...images].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
// Calculate grid layout
const cols = Math.ceil(Math.sqrt(sorted.length));
const maxWidth = Math.max(...sorted.map((img) => img.width));
const maxHeight = Math.max(...sorted.map((img) => img.height));
const positions: ArrangedPosition[] = [];
sorted.forEach((img, index) => {
const row = Math.floor(index / cols);
const col = index % cols;
positions.push({
id: img.id,
x: startX + col * (maxWidth + gridSpacing),
y: startY + row * (maxHeight + gridSpacing),
});
});
return positions;
}

View File

@@ -0,0 +1,57 @@
/**
* Sort images alphabetically by name.
*/
export interface ImageForArrange {
id: string;
filename: string;
x: number;
y: number;
width: number;
height: number;
}
export interface ArrangedPosition {
id: string;
x: number;
y: number;
}
/**
* Arrange images alphabetically by filename.
*
* @param images - Images to arrange
* @param gridSpacing - Spacing between images
* @param startX - Starting X position
* @param startY - Starting Y position
* @returns New positions for images
*/
export function arrangeByName(
images: ImageForArrange[],
gridSpacing: number = 20,
startX: number = 0,
startY: number = 0
): ArrangedPosition[] {
// Sort alphabetically
const sorted = [...images].sort((a, b) => a.filename.localeCompare(b.filename));
// Calculate grid layout
const cols = Math.ceil(Math.sqrt(sorted.length));
const maxWidth = Math.max(...sorted.map((img) => img.width));
const maxHeight = Math.max(...sorted.map((img) => img.height));
const positions: ArrangedPosition[] = [];
sorted.forEach((img, index) => {
const row = Math.floor(index / cols);
const col = index % cols;
positions.push({
id: img.id,
x: startX + col * (maxWidth + gridSpacing),
y: startY + row * (maxHeight + gridSpacing),
});
});
return positions;
}

View File

@@ -0,0 +1,100 @@
/**
* Focus mode for viewing individual images.
*/
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
export interface FocusState {
isActive: boolean;
currentImageId: string | null;
imageIds: string[];
currentIndex: number;
}
function createFocusStore() {
const { subscribe, set, update }: Writable<FocusState> = writable({
isActive: false,
currentImageId: null,
imageIds: [],
currentIndex: 0,
});
return {
subscribe,
/**
* Enter focus mode for a specific image.
*/
enter(imageId: string, allImageIds: string[]) {
const index = allImageIds.indexOf(imageId);
set({
isActive: true,
currentImageId: imageId,
imageIds: allImageIds,
currentIndex: index !== -1 ? index : 0,
});
},
/**
* Exit focus mode.
*/
exit() {
set({
isActive: false,
currentImageId: null,
imageIds: [],
currentIndex: 0,
});
},
/**
* Navigate to next image.
*/
next() {
update((state) => {
if (!state.isActive || state.imageIds.length === 0) return state;
const nextIndex = (state.currentIndex + 1) % state.imageIds.length;
return {
...state,
currentIndex: nextIndex,
currentImageId: state.imageIds[nextIndex],
};
});
},
/**
* Navigate to previous image.
*/
previous() {
update((state) => {
if (!state.isActive || state.imageIds.length === 0) return state;
const prevIndex = (state.currentIndex - 1 + state.imageIds.length) % state.imageIds.length;
return {
...state,
currentIndex: prevIndex,
currentImageId: state.imageIds[prevIndex],
};
});
},
/**
* Go to specific index.
*/
goToIndex(index: number) {
update((state) => {
if (!state.isActive || index < 0 || index >= state.imageIds.length) return state;
return {
...state,
currentIndex: index,
currentImageId: state.imageIds[index],
};
});
},
};
}
export const focusStore = createFocusStore();

View File

@@ -0,0 +1,101 @@
/**
* Image navigation order calculation.
*/
export type NavigationOrder = 'chronological' | 'spatial' | 'alphabetical' | 'random';
export interface ImageWithMetadata {
id: string;
filename: string;
x: number;
y: number;
created_at: string;
}
/**
* Sort images by navigation order preference.
*/
export function sortImagesByOrder(images: ImageWithMetadata[], order: NavigationOrder): string[] {
let sorted: ImageWithMetadata[];
switch (order) {
case 'chronological':
sorted = [...images].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
break;
case 'spatial':
// Left to right, top to bottom
sorted = [...images].sort((a, b) => {
if (Math.abs(a.y - b.y) < 50) {
return a.x - b.x;
}
return a.y - b.y;
});
break;
case 'alphabetical':
sorted = [...images].sort((a, b) => a.filename.localeCompare(b.filename));
break;
case 'random':
sorted = shuffleArray([...images]);
break;
default:
sorted = images;
}
return sorted.map((img) => img.id);
}
/**
* Shuffle array randomly.
*/
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
/**
* Get navigation order preference from localStorage.
*/
export function getNavigationOrderPreference(): NavigationOrder {
if (typeof window === 'undefined') return 'chronological';
try {
const saved = localStorage.getItem('webref_navigation_order');
if (saved && isValidNavigationOrder(saved)) {
return saved as NavigationOrder;
}
} catch (error) {
console.error('Failed to load navigation preference:', error);
}
return 'chronological';
}
/**
* Save navigation order preference.
*/
export function saveNavigationOrderPreference(order: NavigationOrder): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('webref_navigation_order', order);
} catch (error) {
console.error('Failed to save navigation preference:', error);
}
}
/**
* Check if string is a valid navigation order.
*/
function isValidNavigationOrder(value: string): boolean {
return ['chronological', 'spatial', 'alphabetical', 'random'].includes(value);
}

View File

@@ -0,0 +1,145 @@
/**
* Slideshow mode for automatic image presentation.
*/
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
export interface SlideshowState {
isActive: boolean;
isPaused: boolean;
currentImageId: string | null;
imageIds: string[];
currentIndex: number;
interval: number; // seconds
}
const DEFAULT_INTERVAL = 5; // 5 seconds
function createSlideshowStore() {
const { subscribe, set, update }: Writable<SlideshowState> = writable({
isActive: false,
isPaused: false,
currentImageId: null,
imageIds: [],
currentIndex: 0,
interval: DEFAULT_INTERVAL,
});
let timer: ReturnType<typeof setInterval> | null = null;
function clearTimer() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function startTimer(state: SlideshowState, nextFn: () => void) {
clearTimer();
if (state.isActive && !state.isPaused) {
timer = setInterval(nextFn, state.interval * 1000);
}
}
return {
subscribe,
/**
* Start slideshow.
*/
start(imageIds: string[], startIndex: number = 0, interval: number = DEFAULT_INTERVAL) {
const state = {
isActive: true,
isPaused: false,
imageIds,
currentIndex: startIndex,
currentImageId: imageIds[startIndex] || null,
interval,
};
set(state);
startTimer(state, this.next);
},
/**
* Stop slideshow.
*/
stop() {
clearTimer();
set({
isActive: false,
isPaused: false,
currentImageId: null,
imageIds: [],
currentIndex: 0,
interval: DEFAULT_INTERVAL,
});
},
/**
* Pause slideshow.
*/
pause() {
clearTimer();
update((state) => ({ ...state, isPaused: true }));
},
/**
* Resume slideshow.
*/
resume() {
update((state) => {
const newState = { ...state, isPaused: false };
startTimer(newState, this.next);
return newState;
});
},
/**
* Next image.
*/
next() {
update((state) => {
const nextIndex = (state.currentIndex + 1) % state.imageIds.length;
const newState = {
...state,
currentIndex: nextIndex,
currentImageId: state.imageIds[nextIndex],
};
if (!state.isPaused) {
startTimer(newState, this.next);
}
return newState;
});
},
/**
* Previous image.
*/
previous() {
update((state) => {
const prevIndex = (state.currentIndex - 1 + state.imageIds.length) % state.imageIds.length;
return {
...state,
currentIndex: prevIndex,
currentImageId: state.imageIds[prevIndex],
};
});
},
/**
* Set interval.
*/
setInterval(seconds: number) {
update((state) => {
const newState = { ...state, interval: seconds };
if (state.isActive && !state.isPaused) {
startTimer(newState, this.next);
}
return newState;
});
},
};
}
export const slideshowStore = createSlideshowStore();

View File

@@ -0,0 +1,126 @@
/**
* Command registry for command palette.
*/
export interface Command {
id: string;
name: string;
description: string;
category: string;
keywords: string[];
shortcut?: string;
action: () => void | Promise<void>;
}
class CommandRegistry {
private commands: Map<string, Command> = new Map();
private recentlyUsed: string[] = [];
private readonly MAX_RECENT = 10;
/**
* Register a command.
*/
register(command: Command): void {
this.commands.set(command.id, command);
}
/**
* Unregister a command.
*/
unregister(commandId: string): void {
this.commands.delete(commandId);
}
/**
* Get all registered commands.
*/
getAllCommands(): Command[] {
return Array.from(this.commands.values());
}
/**
* Get command by ID.
*/
getCommand(commandId: string): Command | undefined {
return this.commands.get(commandId);
}
/**
* Execute a command.
*/
async execute(commandId: string): Promise<void> {
const command = this.commands.get(commandId);
if (!command) {
console.error(`Command not found: ${commandId}`);
return;
}
try {
await command.action();
this.markAsUsed(commandId);
} catch (error) {
console.error(`Failed to execute command ${commandId}:`, error);
throw error;
}
}
/**
* Mark command as recently used.
*/
private markAsUsed(commandId: string): void {
// Remove if already in list
this.recentlyUsed = this.recentlyUsed.filter((id) => id !== commandId);
// Add to front
this.recentlyUsed.unshift(commandId);
// Keep only MAX_RECENT items
if (this.recentlyUsed.length > this.MAX_RECENT) {
this.recentlyUsed = this.recentlyUsed.slice(0, this.MAX_RECENT);
}
// Persist to localStorage
this.saveRecentlyUsed();
}
/**
* Get recently used commands.
*/
getRecentlyUsed(): Command[] {
return this.recentlyUsed
.map((id) => this.commands.get(id))
.filter((cmd): cmd is Command => cmd !== undefined);
}
/**
* Save recently used commands to localStorage.
*/
private saveRecentlyUsed(): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('webref_recent_commands', JSON.stringify(this.recentlyUsed));
} catch (error) {
console.error('Failed to save recent commands:', error);
}
}
/**
* Load recently used commands from localStorage.
*/
loadRecentlyUsed(): void {
if (typeof window === 'undefined') return;
try {
const saved = localStorage.getItem('webref_recent_commands');
if (saved) {
this.recentlyUsed = JSON.parse(saved);
}
} catch (error) {
console.error('Failed to load recent commands:', error);
}
}
}
// Export singleton instance
export const commandRegistry = new CommandRegistry();

View File

@@ -0,0 +1,93 @@
/**
* Command search and filtering.
*/
import type { Command } from './registry';
/**
* Search commands by query.
*
* @param commands - Array of commands to search
* @param query - Search query
* @returns Filtered and ranked commands
*/
export function searchCommands(commands: Command[], query: string): Command[] {
if (!query || query.trim() === '') {
return commands;
}
const lowerQuery = query.toLowerCase();
// Score each command
const scored = commands
.map((cmd) => ({
command: cmd,
score: calculateScore(cmd, lowerQuery),
}))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score);
return scored.map((item) => item.command);
}
/**
* Calculate relevance score for a command.
*/
function calculateScore(command: Command, query: string): number {
let score = 0;
// Exact name match
if (command.name.toLowerCase() === query) {
score += 100;
}
// Name starts with query
if (command.name.toLowerCase().startsWith(query)) {
score += 50;
}
// Name contains query
if (command.name.toLowerCase().includes(query)) {
score += 25;
}
// Description contains query
if (command.description.toLowerCase().includes(query)) {
score += 10;
}
// Keyword match
for (const keyword of command.keywords) {
if (keyword.toLowerCase() === query) {
score += 30;
} else if (keyword.toLowerCase().startsWith(query)) {
score += 15;
} else if (keyword.toLowerCase().includes(query)) {
score += 5;
}
}
// Category match
if (command.category.toLowerCase().includes(query)) {
score += 5;
}
return score;
}
/**
* Group commands by category.
*/
export function groupCommandsByCategory(commands: Command[]): Map<string, Command[]> {
const grouped = new Map<string, Command[]>();
for (const command of commands) {
const category = command.category || 'Other';
if (!grouped.has(category)) {
grouped.set(category, []);
}
grouped.get(category)!.push(command);
}
return grouped;
}

View File

@@ -0,0 +1,212 @@
<script lang="ts">
import { onMount } from 'svelte';
import { commandRegistry, type Command } from '$lib/commands/registry';
import { searchCommands } from '$lib/commands/search';
export let isOpen: boolean = false;
export let onClose: () => void;
let searchQuery = '';
let allCommands: Command[] = [];
let filteredCommands: Command[] = [];
let selectedIndex = 0;
let searchInput: HTMLInputElement | null = null;
$: {
if (searchQuery) {
filteredCommands = searchCommands(allCommands, searchQuery);
} else {
// Show recently used first when no query
const recent = commandRegistry.getRecentlyUsed();
const otherCommands = allCommands.filter((cmd) => !recent.find((r) => r.id === cmd.id));
filteredCommands = [...recent, ...otherCommands];
}
selectedIndex = 0; // Reset selection when results change
}
onMount(() => {
allCommands = commandRegistry.getAllCommands();
commandRegistry.loadRecentlyUsed();
filteredCommands = commandRegistry.getRecentlyUsed();
// Focus search input when opened
if (isOpen && searchInput) {
searchInput.focus();
}
});
function handleKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
break;
case 'Enter':
event.preventDefault();
executeSelected();
break;
case 'Escape':
event.preventDefault();
onClose();
break;
}
}
async function executeSelected() {
const command = filteredCommands[selectedIndex];
if (command) {
try {
await commandRegistry.execute(command.id);
onClose();
} catch (error) {
console.error('Command execution failed:', error);
}
}
}
function handleCommandClick(command: Command) {
commandRegistry.execute(command.id);
onClose();
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
</script>
{#if isOpen}
<div
class="palette-overlay"
role="button"
tabindex="0"
on:click={handleOverlayClick}
on:keydown={handleKeyDown}
>
<div class="palette" role="dialog" aria-modal="true">
<input
bind:this={searchInput}
type="text"
class="search-input"
placeholder="Type a command or search..."
bind:value={searchQuery}
on:keydown={handleKeyDown}
/>
<div class="commands-list">
{#if filteredCommands.length === 0}
<div class="no-results">No commands found</div>
{:else}
{#each filteredCommands as command, index}
<button
class="command-item"
class:selected={index === selectedIndex}
on:click={() => handleCommandClick(command)}
>
<div class="command-info">
<span class="command-name">{command.name}</span>
<span class="command-description">{command.description}</span>
</div>
{#if command.shortcut}
<span class="command-shortcut">{command.shortcut}</span>
{/if}
</button>
{/each}
{/if}
</div>
</div>
</div>
{/if}
<style>
.palette-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
z-index: 9999;
}
.palette {
background: white;
border-radius: 8px;
width: 90%;
max-width: 600px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.search-input {
width: 100%;
padding: 1rem;
border: none;
border-bottom: 1px solid #e5e7eb;
font-size: 1.125rem;
outline: none;
}
.commands-list {
max-height: 400px;
overflow-y: auto;
}
.no-results {
padding: 2rem;
text-align: center;
color: #9ca3af;
}
.command-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border: none;
background: white;
text-align: left;
cursor: pointer;
transition: background-color 0.15s;
}
.command-item:hover,
.command-item.selected {
background: #f3f4f6;
}
.command-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.command-name {
font-weight: 500;
color: #111827;
}
.command-description {
font-size: 0.875rem;
color: #6b7280;
}
.command-shortcut {
padding: 0.25rem 0.5rem;
background: #e5e7eb;
border-radius: 4px;
font-size: 0.75rem;
font-family: monospace;
color: #6b7280;
}
</style>

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import { qualityStore, type QualityMode, type QualityLevel } from '$lib/stores/quality';
import { runConnectionTest } from '$lib/utils/adaptive-quality';
let mode: QualityMode = 'auto';
let manualLevel: QualityLevel = 'medium';
let detectedLevel: QualityLevel = 'medium';
let connectionSpeed: number = 0;
let testing = false;
// Subscribe to quality store
qualityStore.subscribe((settings) => {
mode = settings.mode;
manualLevel = settings.manualLevel;
detectedLevel = settings.detectedLevel;
connectionSpeed = settings.connectionSpeed;
});
function handleModeChange(event: Event) {
const target = event.target as HTMLSelectElement;
const newMode = target.value as QualityMode;
qualityStore.setMode(newMode);
// Run test immediately when switching to auto mode
if (newMode === 'auto') {
handleTestConnection();
}
}
function handleManualLevelChange(event: Event) {
const target = event.target as HTMLSelectElement;
const newLevel = target.value as QualityLevel;
qualityStore.setManualLevel(newLevel);
}
async function handleTestConnection() {
testing = true;
try {
await runConnectionTest();
} finally {
testing = false;
}
}
function formatSpeed(mbps: number): string {
if (mbps < 1) {
return `${(mbps * 1000).toFixed(0)} Kbps`;
}
return `${mbps.toFixed(1)} Mbps`;
}
</script>
<div class="quality-selector">
<h3>Image Quality Settings</h3>
<div class="form-group">
<label for="mode">Mode:</label>
<select id="mode" value={mode} on:change={handleModeChange}>
<option value="auto">Auto (Detect Connection Speed)</option>
<option value="manual">Manual</option>
</select>
</div>
{#if mode === 'auto'}
<div class="auto-section">
<div class="detected-info">
<p>
<strong>Detected Speed:</strong>
{formatSpeed(connectionSpeed)}
</p>
<p>
<strong>Quality Level:</strong>
<span class="quality-badge {detectedLevel}">{detectedLevel}</span>
</p>
</div>
<button class="btn-test" on:click={handleTestConnection} disabled={testing}>
{testing ? 'Testing...' : 'Test Now'}
</button>
<p class="help-text">Connection speed is re-tested every 5 minutes</p>
</div>
{:else}
<div class="manual-section">
<div class="form-group">
<label for="manual-level">Quality Level:</label>
<select id="manual-level" value={manualLevel} on:change={handleManualLevelChange}>
<option value="low">Low (Fast loading, lower quality)</option>
<option value="medium">Medium (Balanced)</option>
<option value="high">High (Best quality, slower)</option>
<option value="original">Original (Full resolution)</option>
</select>
</div>
</div>
{/if}
</div>
<style>
.quality-selector {
padding: 1rem;
background: #f9fafb;
border-radius: 8px;
}
h3 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
select {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
}
.auto-section,
.manual-section {
margin-top: 1rem;
padding: 1rem;
background: white;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.detected-info {
margin-bottom: 1rem;
}
.detected-info p {
margin: 0.5rem 0;
}
.quality-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 500;
text-transform: capitalize;
}
.quality-badge.low {
background: #fee2e2;
color: #991b1b;
}
.quality-badge.medium {
background: #fef3c7;
color: #92400e;
}
.quality-badge.high {
background: #d1fae5;
color: #065f46;
}
.btn-test {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.btn-test:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.help-text {
font-size: 0.75rem;
color: #6b7280;
margin: 0;
}
</style>

View File

@@ -0,0 +1,138 @@
/**
* Quality settings store for adaptive image quality.
*/
import { writable, derived } from 'svelte/store';
import type { Writable, Readable } from 'svelte/store';
export type QualityLevel = 'low' | 'medium' | 'high' | 'original';
export type QualityMode = 'auto' | 'manual';
export interface QualitySettings {
mode: QualityMode;
manualLevel: QualityLevel;
detectedLevel: QualityLevel;
connectionSpeed: number; // Mbps
lastTestTime: number; // timestamp
}
const STORAGE_KEY = 'webref_quality_settings';
const RETEST_INTERVAL = 5 * 60 * 1000; // 5 minutes
// Load saved settings from localStorage
function loadSettings(): QualitySettings {
if (typeof window === 'undefined') {
return getDefaultSettings();
}
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
return JSON.parse(saved);
}
} catch (error) {
console.error('Failed to load quality settings:', error);
}
return getDefaultSettings();
}
function getDefaultSettings(): QualitySettings {
return {
mode: 'auto',
manualLevel: 'medium',
detectedLevel: 'medium',
connectionSpeed: 3.0,
lastTestTime: 0,
};
}
// Save settings to localStorage
function saveSettings(settings: QualitySettings): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (error) {
console.error('Failed to save quality settings:', error);
}
}
// Create the store
function createQualityStore() {
const { subscribe, set, update }: Writable<QualitySettings> = writable(loadSettings());
return {
subscribe,
/**
* Set quality mode (auto or manual).
*/
setMode(mode: QualityMode) {
update((settings) => {
const updated = { ...settings, mode };
saveSettings(updated);
return updated;
});
},
/**
* Set manual quality level.
*/
setManualLevel(level: QualityLevel) {
update((settings) => {
const updated = { ...settings, manualLevel: level };
saveSettings(updated);
return updated;
});
},
/**
* Update detected quality level based on connection test.
*/
updateDetectedQuality(speed: number, level: QualityLevel) {
update((settings) => {
const updated = {
...settings,
detectedLevel: level,
connectionSpeed: speed,
lastTestTime: Date.now(),
};
saveSettings(updated);
return updated;
});
},
/**
* Check if connection test should be run.
*/
shouldRetest(): boolean {
const settings = loadSettings();
if (settings.mode !== 'auto') return false;
const timeSinceTest = Date.now() - settings.lastTestTime;
return timeSinceTest > RETEST_INTERVAL;
},
/**
* Reset to default settings.
*/
reset() {
const defaults = getDefaultSettings();
set(defaults);
saveSettings(defaults);
},
};
}
// Export the store
export const qualityStore = createQualityStore();
// Derived store for active quality level (respects mode)
export const activeQuality: Readable<QualityLevel> = derived(qualityStore, ($quality) => {
if ($quality.mode === 'manual') {
return $quality.manualLevel;
} else {
return $quality.detectedLevel;
}
});

View File

@@ -0,0 +1,82 @@
/**
* Adaptive image quality logic.
*/
import { testConnectionSpeed, determineQualityTier } from './connection-test';
import { qualityStore } from '$lib/stores/quality';
import { get } from 'svelte/store';
/**
* Initialize adaptive quality system.
* Tests connection speed if in auto mode and needed.
*/
export async function initializeAdaptiveQuality(): Promise<void> {
const settings = get(qualityStore);
if (settings.mode === 'auto' && qualityStore.shouldRetest()) {
await runConnectionTest();
}
// Set up periodic re-testing in auto mode
if (settings.mode === 'auto') {
schedulePeriodicTest();
}
}
/**
* Run connection speed test and update quality settings.
*/
export async function runConnectionTest(): Promise<void> {
try {
const result = await testConnectionSpeed();
const qualityLevel = determineQualityTier(result.speed_mbps);
qualityStore.updateDetectedQuality(result.speed_mbps, qualityLevel);
} catch (error) {
console.error('Connection test failed:', error);
// Keep current settings on error
}
}
/**
* Schedule periodic connection testing (every 5 minutes in auto mode).
*/
function schedulePeriodicTest(): void {
const RETEST_INTERVAL = 5 * 60 * 1000; // 5 minutes
setInterval(() => {
const settings = get(qualityStore);
if (settings.mode === 'auto') {
runConnectionTest();
}
}, RETEST_INTERVAL);
}
/**
* Get thumbnail URL for specified quality level.
*
* @param imageId - Image UUID
* @param quality - Quality level
* @returns Thumbnail URL
*/
export function getThumbnailUrl(
imageId: string,
quality: 'low' | 'medium' | 'high' | 'original' = 'medium'
): string {
if (quality === 'original') {
return `/api/v1/images/${imageId}/original`;
}
return `/api/v1/images/${imageId}/thumbnail/${quality}`;
}
/**
* Get appropriate thumbnail URL based on current quality settings.
*
* @param imageId - Image UUID
* @returns Thumbnail URL for current quality level
*/
export function getAdaptiveThumbnailUrl(imageId: string): string {
const settings = get(qualityStore);
const quality = settings.mode === 'auto' ? settings.detectedLevel : settings.manualLevel;
return getThumbnailUrl(imageId, quality);
}

View File

@@ -0,0 +1,120 @@
/**
* Connection speed testing utilities.
*/
export interface ConnectionTestResult {
speed_mbps: number;
latency_ms: number;
quality_tier: 'low' | 'medium' | 'high';
}
/**
* Test connection speed by downloading test data.
*
* @param testSizeBytes - Size of test data to download (default 100KB)
* @returns Connection test results
*/
export async function testConnectionSpeed(
testSizeBytes: number = 100000
): Promise<ConnectionTestResult> {
try {
// Use Network Information API if available
interface NavigatorWithConnection extends Navigator {
connection?: {
effectiveType?: string;
};
}
const connection = (navigator as NavigatorWithConnection).connection;
if (connection && connection.effectiveType) {
const effectiveType = connection.effectiveType;
return estimateFromEffectiveType(effectiveType);
}
// Fall back to download speed test
const startTime = performance.now();
const response = await fetch(`/api/v1/connection/test-data?size=${testSizeBytes}`, {
method: 'GET',
cache: 'no-cache',
});
if (!response.ok) {
throw new Error('Connection test failed');
}
// Download the data
const data = await response.arrayBuffer();
const endTime = performance.now();
// Calculate speed
const durationSeconds = (endTime - startTime) / 1000;
const dataSizeBits = data.byteLength * 8;
const speedMbps = dataSizeBits / durationSeconds / 1_000_000;
const latencyMs = endTime - startTime;
// Determine quality tier
const qualityTier = determineQualityTier(speedMbps);
return {
speed_mbps: speedMbps,
latency_ms: latencyMs,
quality_tier: qualityTier,
};
} catch (error) {
console.error('Connection test failed:', error);
// Return medium quality as fallback
return {
speed_mbps: 3.0,
latency_ms: 100,
quality_tier: 'medium',
};
}
}
/**
* Estimate connection speed from Network Information API effective type.
*
* @param effectiveType - Effective connection type from Network Information API
* @returns Estimated connection test result
*/
function estimateFromEffectiveType(effectiveType: string): ConnectionTestResult {
const estimates: Record<string, ConnectionTestResult> = {
'slow-2g': { speed_mbps: 0.05, latency_ms: 2000, quality_tier: 'low' },
'2g': { speed_mbps: 0.25, latency_ms: 1400, quality_tier: 'low' },
'3g': { speed_mbps: 0.7, latency_ms: 270, quality_tier: 'low' },
'4g': { speed_mbps: 10.0, latency_ms: 50, quality_tier: 'high' },
};
return estimates[effectiveType] || estimates['4g'];
}
/**
* Determine quality tier based on connection speed.
*
* @param speedMbps - Connection speed in Mbps
* @returns Quality tier
*/
export function determineQualityTier(speedMbps: number): 'low' | 'medium' | 'high' {
if (speedMbps < 1.0) {
return 'low';
} else if (speedMbps < 5.0) {
return 'medium';
} else {
return 'high';
}
}
/**
* Check if Network Information API is available.
*
* @returns True if available
*/
export function isNetworkInformationAvailable(): boolean {
interface NavigatorWithConnection extends Navigator {
connection?: {
effectiveType?: string;
};
}
const nav = navigator as NavigatorWithConnection;
return 'connection' in nav && !!nav.connection && 'effectiveType' in nav.connection;
}

View File

@@ -0,0 +1,284 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
listLibraryImages,
deleteLibraryImage,
getLibraryStats,
type LibraryImage,
type LibraryStats,
} from '$lib/api/library';
let images: LibraryImage[] = [];
let stats: LibraryStats | null = null;
let loading = true;
let error = '';
let searchQuery = '';
let _showAddToBoard = false;
let _selectedImage: LibraryImage | null = null;
onMount(async () => {
await loadLibrary();
await loadStats();
});
async function loadLibrary() {
try {
loading = true;
const result = await listLibraryImages(searchQuery || undefined);
images = result.images;
} catch (err: any) {
error = `Failed to load library: ${err.message || err}`;
} finally {
loading = false;
}
}
async function loadStats() {
try {
stats = await getLibraryStats();
} catch (err) {
console.error('Failed to load stats:', err);
}
}
async function handleSearch() {
await loadLibrary();
}
async function handleDelete(imageId: string) {
if (!confirm('Permanently delete this image? It will be removed from all boards.')) {
return;
}
try {
await deleteLibraryImage(imageId);
await loadLibrary();
await loadStats();
} catch (err: any) {
error = `Failed to delete image: ${err.message || err}`;
}
}
function handleAddToBoard(image: LibraryImage) {
_selectedImage = image;
_showAddToBoard = true;
// TODO: Implement add to board modal
alert('Add to board feature coming soon!');
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
</script>
<div class="library-page">
<div class="page-header">
<h1>Image Library</h1>
{#if stats}
<div class="stats">
<span><strong>{stats.total_images}</strong> images</span>
<span><strong>{formatBytes(stats.total_size_bytes)}</strong> total</span>
<span><strong>{stats.total_board_references}</strong> board uses</span>
</div>
{/if}
</div>
<div class="search-bar">
<input
type="text"
placeholder="Search images..."
bind:value={searchQuery}
on:keydown={(e) => e.key === 'Enter' && handleSearch()}
/>
<button class="btn-search" on:click={handleSearch}>Search</button>
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if loading}
<div class="loading">Loading library...</div>
{:else if images.length === 0}
<div class="empty-state">
<p>No images in your library yet.</p>
<p>Upload images to boards to add them to your library.</p>
</div>
{:else}
<div class="image-grid">
{#each images as image}
<div class="image-card">
{#if image.thumbnail_url}
<img src={image.thumbnail_url} alt={image.filename} class="thumbnail" />
{:else}
<div class="no-thumbnail">No preview</div>
{/if}
<div class="image-info">
<p class="filename">{image.filename}</p>
<p class="details">
{image.width}x{image.height}{formatBytes(image.file_size)}
</p>
<p class="references">
Used on {image.reference_count} board{image.reference_count !== 1 ? 's' : ''}
</p>
</div>
<div class="image-actions">
<button class="btn-add" on:click={() => handleAddToBoard(image)}> Add to Board </button>
<button class="btn-delete" on:click={() => handleDelete(image.id)}> Delete </button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.library-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
.stats {
display: flex;
gap: 2rem;
color: #6b7280;
}
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.search-bar input {
flex: 1;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
}
.btn-search {
background: #3b82f6;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.error-message {
background: #fee2e2;
color: #991b1b;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.loading,
.empty-state {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.empty-state p {
margin: 0.5rem 0;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.image-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
background: white;
transition: box-shadow 0.2s;
}
.image-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.thumbnail {
width: 100%;
height: 200px;
object-fit: cover;
background: #f3f4f6;
}
.no-thumbnail {
width: 100%;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
color: #9ca3af;
}
.image-info {
padding: 1rem;
}
.filename {
font-weight: 500;
margin: 0 0 0.5rem 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.details,
.references {
font-size: 0.875rem;
color: #6b7280;
margin: 0.25rem 0;
}
.image-actions {
display: flex;
gap: 0.5rem;
padding: 0 1rem 1rem 1rem;
}
.btn-add,
.btn-delete {
flex: 1;
padding: 0.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-add {
background: #10b981;
color: white;
}
.btn-delete {
background: #ef4444;
color: white;
}
</style>