phase 13
All checks were successful
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 4s
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / Nix Flake Check (push) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 0s
CI/CD Pipeline / Frontend Linting (push) Successful in 17s
All checks were successful
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 4s
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / Nix Flake Check (push) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 0s
CI/CD Pipeline / Frontend Linting (push) Successful in 17s
This commit is contained in:
69
frontend/src/lib/api/groups.ts
Normal file
69
frontend/src/lib/api/groups.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Groups API client
|
||||
* Handles group creation, update, deletion
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface GroupCreateData {
|
||||
name: string;
|
||||
color: string;
|
||||
annotation?: string;
|
||||
image_ids: string[];
|
||||
}
|
||||
|
||||
export interface GroupUpdateData {
|
||||
name?: string;
|
||||
color?: string;
|
||||
annotation?: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
board_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
annotation: string | null;
|
||||
member_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group
|
||||
*/
|
||||
export async function createGroup(boardId: string, data: GroupCreateData): Promise<Group> {
|
||||
return apiClient.post<Group>(`/api/boards/${boardId}/groups`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all groups on a board
|
||||
*/
|
||||
export async function listGroups(boardId: string): Promise<Group[]> {
|
||||
return apiClient.get<Group[]>(`/api/boards/${boardId}/groups`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific group
|
||||
*/
|
||||
export async function getGroup(boardId: string, groupId: string): Promise<Group> {
|
||||
return apiClient.get<Group>(`/api/boards/${boardId}/groups/${groupId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group metadata
|
||||
*/
|
||||
export async function updateGroup(
|
||||
boardId: string,
|
||||
groupId: string,
|
||||
data: GroupUpdateData
|
||||
): Promise<Group> {
|
||||
return apiClient.patch<Group>(`/api/boards/${boardId}/groups/${groupId}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a group (ungroups all members)
|
||||
*/
|
||||
export async function deleteGroup(boardId: string, groupId: string): Promise<void> {
|
||||
await apiClient.delete(`/api/boards/${boardId}/groups/${groupId}`);
|
||||
}
|
||||
107
frontend/src/lib/canvas/GroupVisual.svelte
Normal file
107
frontend/src/lib/canvas/GroupVisual.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Group visual indicator for canvas
|
||||
* Draws visual borders and labels for grouped images
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Konva from 'konva';
|
||||
import type { Group } from '$lib/api/groups';
|
||||
|
||||
export let layer: Konva.Layer | null = null;
|
||||
export let group: Group;
|
||||
export let getGroupBounds: () => { x: number; y: number; width: number; height: number } | null;
|
||||
|
||||
let groupVisual: Konva.Group | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (!layer) return;
|
||||
|
||||
// Create group visual
|
||||
groupVisual = new Konva.Group({
|
||||
listening: false,
|
||||
name: `group-visual-${group.id}`,
|
||||
});
|
||||
|
||||
layer.add(groupVisual);
|
||||
updateVisual();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (groupVisual) {
|
||||
groupVisual.destroy();
|
||||
groupVisual = null;
|
||||
}
|
||||
if (layer) {
|
||||
layer.batchDraw();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update group visual based on member positions
|
||||
*/
|
||||
export function updateVisual() {
|
||||
if (!groupVisual || !layer) return;
|
||||
|
||||
// Clear existing visuals
|
||||
groupVisual.destroyChildren();
|
||||
|
||||
const bounds = getGroupBounds();
|
||||
if (!bounds) {
|
||||
layer.batchDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw group border
|
||||
const border = new Konva.Rect({
|
||||
x: bounds.x - 10,
|
||||
y: bounds.y - 10,
|
||||
width: bounds.width + 20,
|
||||
height: bounds.height + 20,
|
||||
stroke: group.color,
|
||||
strokeWidth: 3,
|
||||
dash: [10, 5],
|
||||
cornerRadius: 8,
|
||||
listening: false,
|
||||
});
|
||||
|
||||
groupVisual.add(border);
|
||||
|
||||
// Draw group label
|
||||
const labelBg = new Konva.Rect({
|
||||
x: bounds.x - 10,
|
||||
y: bounds.y - 35,
|
||||
height: 24,
|
||||
fill: group.color,
|
||||
cornerRadius: 4,
|
||||
listening: false,
|
||||
});
|
||||
|
||||
const labelText = new Konva.Text({
|
||||
x: bounds.x - 5,
|
||||
y: bounds.y - 31,
|
||||
text: group.name,
|
||||
fontSize: 14,
|
||||
fontStyle: 'bold',
|
||||
fill: '#ffffff',
|
||||
listening: false,
|
||||
});
|
||||
|
||||
// Adjust background width to fit text
|
||||
labelBg.width(labelText.width() + 10);
|
||||
|
||||
groupVisual.add(labelBg);
|
||||
groupVisual.add(labelText);
|
||||
|
||||
// Move to bottom so it doesn't cover images
|
||||
groupVisual.moveToBottom();
|
||||
|
||||
layer.batchDraw();
|
||||
}
|
||||
|
||||
// Reactive updates
|
||||
$: if (group && groupVisual) {
|
||||
updateVisual();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- This component doesn't render any DOM, it only manages Konva nodes -->
|
||||
118
frontend/src/lib/canvas/operations/group-move.ts
Normal file
118
frontend/src/lib/canvas/operations/group-move.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Group move operations
|
||||
* Move all images in a group together as a unit
|
||||
*/
|
||||
|
||||
import type Konva from 'konva';
|
||||
|
||||
export interface GroupMoveOptions {
|
||||
animate?: boolean;
|
||||
onMoveComplete?: (groupId: string, deltaX: number, deltaY: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move all images in a group by delta
|
||||
*/
|
||||
export function moveGroupBy(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
imageIdsInGroup: string[],
|
||||
groupId: string,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
options: GroupMoveOptions = {}
|
||||
): void {
|
||||
const { animate = false, onMoveComplete } = options;
|
||||
|
||||
imageIdsInGroup.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const newX = image.x() + deltaX;
|
||||
const newY = image.y() + deltaY;
|
||||
|
||||
if (animate) {
|
||||
image.to({
|
||||
x: newX,
|
||||
y: newY,
|
||||
duration: 0.3,
|
||||
});
|
||||
} else {
|
||||
image.position({ x: newX, y: newY });
|
||||
}
|
||||
});
|
||||
|
||||
// Batch draw
|
||||
const firstImage = imageIdsInGroup.length > 0 ? images.get(imageIdsInGroup[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (onMoveComplete) {
|
||||
onMoveComplete(groupId, deltaX, deltaY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move group to specific position (aligns top-left)
|
||||
*/
|
||||
export function moveGroupTo(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
imageIdsInGroup: string[],
|
||||
groupId: string,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
options: GroupMoveOptions = {}
|
||||
): void {
|
||||
// Find current top-left
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
|
||||
imageIdsInGroup.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
minX = Math.min(minX, box.x);
|
||||
minY = Math.min(minY, box.y);
|
||||
});
|
||||
|
||||
if (!isFinite(minX) || !isFinite(minY)) return;
|
||||
|
||||
const deltaX = targetX - minX;
|
||||
const deltaY = targetY - minY;
|
||||
|
||||
moveGroupBy(images, imageIdsInGroup, groupId, deltaX, deltaY, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group bounding box
|
||||
*/
|
||||
export function getGroupBounds(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
imageIdsInGroup: string[]
|
||||
): { x: number; y: number; width: number; height: number } | null {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
imageIdsInGroup.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
minX = Math.min(minX, box.x);
|
||||
minY = Math.min(minY, box.y);
|
||||
maxX = Math.max(maxX, box.x + box.width);
|
||||
maxY = Math.max(maxY, box.y + box.height);
|
||||
});
|
||||
|
||||
if (!isFinite(minX) || !isFinite(minY)) return null;
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
83
frontend/src/lib/canvas/operations/group.ts
Normal file
83
frontend/src/lib/canvas/operations/group.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Group operations for canvas images
|
||||
* Create groups from selected images
|
||||
*/
|
||||
|
||||
import type { Group } from '$lib/api/groups';
|
||||
|
||||
export interface CreateGroupOptions {
|
||||
name: string;
|
||||
color: string;
|
||||
annotation?: string;
|
||||
onGroupCreate?: (group: Group) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create group from selected images
|
||||
*/
|
||||
export async function createGroupFromSelection(
|
||||
selectedIds: string[],
|
||||
boardId: string,
|
||||
options: CreateGroupOptions
|
||||
): Promise<Group | null> {
|
||||
if (selectedIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { createGroup } = await import('$lib/api/groups');
|
||||
|
||||
try {
|
||||
const group = await createGroup(boardId, {
|
||||
name: options.name,
|
||||
color: options.color,
|
||||
annotation: options.annotation,
|
||||
image_ids: selectedIds,
|
||||
});
|
||||
|
||||
if (options.onGroupCreate) {
|
||||
options.onGroupCreate(group);
|
||||
}
|
||||
|
||||
return group;
|
||||
} catch (error) {
|
||||
console.error('Failed to create group:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all selected images can be grouped
|
||||
*/
|
||||
export function canCreateGroup(selectedIds: string[]): boolean {
|
||||
return selectedIds.length >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group color suggestions
|
||||
*/
|
||||
export function getGroupColorSuggestions(): string[] {
|
||||
return [
|
||||
'#FF5733', // Red
|
||||
'#3B82F6', // Blue
|
||||
'#10B981', // Green
|
||||
'#F59E0B', // Yellow
|
||||
'#8B5CF6', // Purple
|
||||
'#EC4899', // Pink
|
||||
'#14B8A6', // Teal
|
||||
'#F97316', // Orange
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate default group name
|
||||
*/
|
||||
export function generateDefaultGroupName(existingGroups: Group[]): string {
|
||||
const baseName = 'Group';
|
||||
let counter = existingGroups.length + 1;
|
||||
|
||||
while (existingGroups.some((g) => g.name === `${baseName} ${counter}`)) {
|
||||
counter++;
|
||||
}
|
||||
|
||||
return `${baseName} ${counter}`;
|
||||
}
|
||||
58
frontend/src/lib/canvas/operations/ungroup.ts
Normal file
58
frontend/src/lib/canvas/operations/ungroup.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Ungroup operations
|
||||
* Remove images from groups
|
||||
*/
|
||||
|
||||
export interface UngroupOptions {
|
||||
onUngroupComplete?: (imageIds: string[], groupId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ungroup images (remove from group)
|
||||
*/
|
||||
export async function ungroupImages(
|
||||
boardId: string,
|
||||
groupId: string,
|
||||
options: UngroupOptions = {}
|
||||
): Promise<boolean> {
|
||||
const { deleteGroup } = await import('$lib/api/groups');
|
||||
|
||||
try {
|
||||
await deleteGroup(boardId, groupId);
|
||||
|
||||
if (options.onUngroupComplete) {
|
||||
// Note: We'd need to track which images were in the group
|
||||
options.onUngroupComplete([], groupId);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to ungroup:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove specific images from group
|
||||
*/
|
||||
export async function removeImagesFromGroup(
|
||||
boardId: string,
|
||||
groupId: string,
|
||||
imageIds: string[]
|
||||
): Promise<boolean> {
|
||||
// Update board images to remove group_id
|
||||
const { apiClient } = await import('$lib/api/client');
|
||||
|
||||
try {
|
||||
for (const imageId of imageIds) {
|
||||
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
|
||||
group_id: null,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to remove images from group:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
245
frontend/src/lib/components/canvas/ColorPicker.svelte
Normal file
245
frontend/src/lib/components/canvas/ColorPicker.svelte
Normal file
@@ -0,0 +1,245 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Color picker component for groups
|
||||
* Allows selecting colors for group labels
|
||||
*/
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { getGroupColorSuggestions } from '$lib/canvas/operations/group';
|
||||
|
||||
export let selectedColor: string = '#3B82F6';
|
||||
export let show: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const colorSuggestions = getGroupColorSuggestions();
|
||||
let customColor = selectedColor;
|
||||
|
||||
function handleColorSelect(color: string) {
|
||||
selectedColor = color;
|
||||
customColor = color;
|
||||
dispatch('select', { color });
|
||||
show = false;
|
||||
}
|
||||
|
||||
function handleCustomColorChange(event: Event) {
|
||||
const color = (event.target as HTMLInputElement).value;
|
||||
customColor = color;
|
||||
}
|
||||
|
||||
function handleCustomColorSelect() {
|
||||
selectedColor = customColor;
|
||||
dispatch('select', { color: customColor });
|
||||
show = false;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
show = false;
|
||||
}
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div
|
||||
class="color-picker-backdrop"
|
||||
on:click={handleBackdropClick}
|
||||
on:keydown={(e) => e.key === 'Escape' && handleClose()}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="color-picker" role="dialog">
|
||||
<div class="picker-header">
|
||||
<h4>Choose Color</h4>
|
||||
<button class="close-button" on:click={handleClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div class="color-presets">
|
||||
{#each colorSuggestions as color}
|
||||
<button
|
||||
class="color-swatch"
|
||||
class:selected={selectedColor === color}
|
||||
style="background-color: {color}"
|
||||
on:click={() => handleColorSelect(color)}
|
||||
title={color}
|
||||
>
|
||||
{#if selectedColor === color}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="3"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="custom-color">
|
||||
<label for="custom-color-input">Custom Color</label>
|
||||
<div class="custom-color-input">
|
||||
<input
|
||||
id="custom-color-input"
|
||||
type="color"
|
||||
bind:value={customColor}
|
||||
on:change={handleCustomColorChange}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customColor}
|
||||
on:input={handleCustomColorChange}
|
||||
placeholder="#RRGGBB"
|
||||
maxlength="7"
|
||||
/>
|
||||
<button class="button-small" on:click={handleCustomColorSelect}>Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.color-picker-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
background-color: var(--color-bg, #ffffff);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
max-width: 320px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.picker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.picker-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.75rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background-color: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
.color-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-swatch:hover {
|
||||
transform: scale(1.1);
|
||||
border-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.color-swatch.selected {
|
||||
border-color: var(--color-text, #111827);
|
||||
box-shadow: 0 0 0 2px var(--color-bg, #ffffff);
|
||||
}
|
||||
|
||||
.custom-color {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.custom-color label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.custom-color-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input[type='color'] {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.button-small {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: var(--color-primary, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-small:hover {
|
||||
background-color: var(--color-primary-hover, #2563eb);
|
||||
}
|
||||
</style>
|
||||
266
frontend/src/lib/components/canvas/GroupAnnotation.svelte
Normal file
266
frontend/src/lib/components/canvas/GroupAnnotation.svelte
Normal file
@@ -0,0 +1,266 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Group annotation UI component
|
||||
* Displays and edits group name, color, and annotation
|
||||
*/
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { Group } from '$lib/api/groups';
|
||||
|
||||
export let group: Group;
|
||||
export let editing: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let editName = group.name;
|
||||
let editAnnotation = group.annotation || '';
|
||||
|
||||
function handleSave() {
|
||||
dispatch('save', {
|
||||
name: editName,
|
||||
annotation: editAnnotation || null,
|
||||
});
|
||||
editing = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
editName = group.name;
|
||||
editAnnotation = group.annotation || '';
|
||||
editing = false;
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
dispatch('delete');
|
||||
}
|
||||
|
||||
function handleColorChange() {
|
||||
dispatch('color-change');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="group-annotation" style="border-left: 4px solid {group.color}">
|
||||
<div class="annotation-header">
|
||||
<button
|
||||
class="color-indicator"
|
||||
style="background-color: {group.color}"
|
||||
on:click={handleColorChange}
|
||||
title="Change color"
|
||||
/>
|
||||
|
||||
{#if editing}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
class="name-input"
|
||||
placeholder="Group name"
|
||||
maxlength="255"
|
||||
/>
|
||||
{:else}
|
||||
<h4 class="group-name" on:dblclick={handleEdit}>{group.name}</h4>
|
||||
{/if}
|
||||
|
||||
<div class="header-actions">
|
||||
<span class="member-count" title="{group.member_count} images">
|
||||
{group.member_count}
|
||||
</span>
|
||||
|
||||
{#if !editing}
|
||||
<button class="icon-button" on:click={handleEdit} title="Edit group"> ✎ </button>
|
||||
<button class="icon-button delete" on:click={handleDelete} title="Delete group"> × </button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="annotation-body">
|
||||
{#if editing}
|
||||
<textarea
|
||||
bind:value={editAnnotation}
|
||||
class="annotation-input"
|
||||
placeholder="Add annotation..."
|
||||
maxlength="10000"
|
||||
rows="3"
|
||||
/>
|
||||
<div class="edit-actions">
|
||||
<button class="button button-secondary" on:click={handleCancel}>Cancel</button>
|
||||
<button class="button button-primary" on:click={handleSave}>Save</button>
|
||||
</div>
|
||||
{:else if group.annotation}
|
||||
<p class="annotation-text" on:dblclick={handleEdit}>{group.annotation}</p>
|
||||
{:else}
|
||||
<p class="annotation-empty" on:dblclick={handleEdit}>No annotation</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.group-annotation {
|
||||
background-color: var(--color-bg, #ffffff);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.annotation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.color-indicator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-indicator:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.group-name {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-name:hover {
|
||||
color: var(--color-primary, #3b82f6);
|
||||
}
|
||||
|
||||
.name-input {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.member-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 0.5rem;
|
||||
background-color: var(--color-bg-secondary, #f3f4f6);
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background-color: var(--color-bg-hover, #f3f4f6);
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.icon-button.delete:hover {
|
||||
background-color: var(--color-error-bg, #fee2e2);
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
.annotation-body {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.annotation-text {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
white-space: pre-wrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.annotation-text:hover {
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.annotation-empty {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-style: italic;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.annotation-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background-color: var(--color-bg-secondary, #f3f4f6);
|
||||
color: var(--color-text, #374151);
|
||||
border-color: var(--color-border, #d1d5db);
|
||||
}
|
||||
|
||||
.button-secondary:hover {
|
||||
background-color: var(--color-bg-hover, #e5e7eb);
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background-color: var(--color-primary, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background-color: var(--color-primary-hover, #2563eb);
|
||||
}
|
||||
</style>
|
||||
158
frontend/src/lib/stores/groups.ts
Normal file
158
frontend/src/lib/stores/groups.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Groups store for managing image groups
|
||||
* Handles group state and operations
|
||||
*/
|
||||
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import * as groupsApi from '$lib/api/groups';
|
||||
import type { Group, GroupCreateData, GroupUpdateData } from '$lib/api/groups';
|
||||
|
||||
export interface GroupsState {
|
||||
groups: Group[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: GroupsState = {
|
||||
groups: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create groups store
|
||||
*/
|
||||
function createGroupsStore() {
|
||||
const { subscribe, set, update }: Writable<GroupsState> = writable(DEFAULT_STATE);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
update,
|
||||
|
||||
/**
|
||||
* Load groups for a board
|
||||
*/
|
||||
load: async (boardId: string) => {
|
||||
update((state) => ({ ...state, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const groups = await groupsApi.listGroups(boardId);
|
||||
set({ groups, loading: false, error: null });
|
||||
} catch (error) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load groups',
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new group
|
||||
*/
|
||||
create: async (boardId: string, data: GroupCreateData): Promise<Group | null> => {
|
||||
update((state) => ({ ...state, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const group = await groupsApi.createGroup(boardId, data);
|
||||
update((state) => ({
|
||||
groups: [group, ...state.groups],
|
||||
loading: false,
|
||||
error: null,
|
||||
}));
|
||||
return group;
|
||||
} catch (error) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create group',
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a group
|
||||
*/
|
||||
updateGroup: async (
|
||||
boardId: string,
|
||||
groupId: string,
|
||||
data: GroupUpdateData
|
||||
): Promise<Group | null> => {
|
||||
update((state) => ({ ...state, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const group = await groupsApi.updateGroup(boardId, groupId, data);
|
||||
update((state) => ({
|
||||
groups: state.groups.map((g) => (g.id === groupId ? group : g)),
|
||||
loading: false,
|
||||
error: null,
|
||||
}));
|
||||
return group;
|
||||
} catch (error) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update group',
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a group
|
||||
*/
|
||||
delete: async (boardId: string, groupId: string): Promise<boolean> => {
|
||||
update((state) => ({ ...state, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
await groupsApi.deleteGroup(boardId, groupId);
|
||||
update((state) => ({
|
||||
groups: state.groups.filter((g) => g.id !== groupId),
|
||||
loading: false,
|
||||
error: null,
|
||||
}));
|
||||
return true;
|
||||
} catch (error) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete group',
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get group by ID
|
||||
*/
|
||||
getById: (groupId: string): Group | null => {
|
||||
let result: Group | null = null;
|
||||
const unsubscribe = subscribe((state) => {
|
||||
result = state.groups.find((g) => g.id === groupId) || null;
|
||||
});
|
||||
unsubscribe();
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all groups
|
||||
*/
|
||||
clear: () => {
|
||||
set(DEFAULT_STATE);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const groups = createGroupsStore();
|
||||
|
||||
// Derived stores
|
||||
export const groupsLoading = derived(groups, ($groups) => $groups.loading);
|
||||
|
||||
export const groupsError = derived(groups, ($groups) => $groups.error);
|
||||
|
||||
export const groupsList = derived(groups, ($groups) => $groups.groups);
|
||||
|
||||
export const groupCount = derived(groups, ($groups) => $groups.groups.length);
|
||||
219
frontend/tests/canvas/groups.test.ts
Normal file
219
frontend/tests/canvas/groups.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Tests for grouping operations
|
||||
* Tests group creation, moving groups, ungrouping
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Group } from '$lib/api/groups';
|
||||
import {
|
||||
createGroupFromSelection,
|
||||
canCreateGroup,
|
||||
getGroupColorSuggestions,
|
||||
generateDefaultGroupName,
|
||||
} from '$lib/canvas/operations/group';
|
||||
import { ungroupImages, removeImagesFromGroup } from '$lib/canvas/operations/ungroup';
|
||||
import { groups, groupsLoading, groupsError, groupCount } from '$lib/stores/groups';
|
||||
|
||||
// Mock API
|
||||
vi.mock('$lib/api/groups', () => ({
|
||||
createGroup: vi.fn().mockResolvedValue({
|
||||
id: 'group-1',
|
||||
board_id: 'board-1',
|
||||
name: 'Test Group',
|
||||
color: '#FF5733',
|
||||
annotation: 'Test',
|
||||
member_count: 2,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}),
|
||||
listGroups: vi.fn().mockResolvedValue([]),
|
||||
updateGroup: vi.fn().mockResolvedValue({
|
||||
id: 'group-1',
|
||||
name: 'Updated',
|
||||
color: '#00FF00',
|
||||
}),
|
||||
deleteGroup: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe('Group Creation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('can create group from selection', () => {
|
||||
const selectedIds = ['img1', 'img2'];
|
||||
expect(canCreateGroup(selectedIds)).toBe(true);
|
||||
});
|
||||
|
||||
it('cannot create group with no selection', () => {
|
||||
expect(canCreateGroup([])).toBe(false);
|
||||
});
|
||||
|
||||
it('creates group from selection', async () => {
|
||||
const selectedIds = ['img1', 'img2'];
|
||||
|
||||
const group = await createGroupFromSelection(selectedIds, 'board-1', {
|
||||
name: 'Test Group',
|
||||
color: '#FF5733',
|
||||
annotation: 'Test annotation',
|
||||
});
|
||||
|
||||
expect(group).not.toBeNull();
|
||||
expect(group?.name).toBe('Test Group');
|
||||
});
|
||||
|
||||
it('calls callback on group creation', async () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
await createGroupFromSelection(['img1', 'img2'], 'board-1', {
|
||||
name: 'Test Group',
|
||||
color: '#FF5733',
|
||||
onGroupCreate: callback,
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generates default group names', () => {
|
||||
const existingGroups: Group[] = [
|
||||
{
|
||||
id: '1',
|
||||
board_id: 'board-1',
|
||||
name: 'Group 1',
|
||||
color: '#FF5733',
|
||||
annotation: null,
|
||||
member_count: 2,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
board_id: 'board-1',
|
||||
name: 'Group 2',
|
||||
color: '#FF5733',
|
||||
annotation: null,
|
||||
member_count: 3,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const newName = generateDefaultGroupName(existingGroups);
|
||||
expect(newName).toBe('Group 3');
|
||||
});
|
||||
|
||||
it('provides color suggestions', () => {
|
||||
const colors = getGroupColorSuggestions();
|
||||
|
||||
expect(colors).toBeInstanceOf(Array);
|
||||
expect(colors.length).toBeGreaterThan(0);
|
||||
expect(colors[0]).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Groups Store', () => {
|
||||
beforeEach(() => {
|
||||
groups.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('starts with empty state', () => {
|
||||
const state = get(groups);
|
||||
|
||||
expect(state.groups).toEqual([]);
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('loads groups', async () => {
|
||||
await groups.load('board-1');
|
||||
|
||||
expect(get(groupsLoading)).toBe(false);
|
||||
});
|
||||
|
||||
it('creates group', async () => {
|
||||
const groupData = {
|
||||
name: 'New Group',
|
||||
color: '#FF5733',
|
||||
image_ids: ['img1', 'img2'],
|
||||
};
|
||||
|
||||
const group = await groups.create('board-1', groupData);
|
||||
|
||||
expect(group).not.toBeNull();
|
||||
expect(get(groupCount)).toBe(1);
|
||||
});
|
||||
|
||||
it('handles creation error', async () => {
|
||||
const { createGroup } = await import('$lib/api/groups');
|
||||
vi.mocked(createGroup).mockRejectedValueOnce(new Error('API Error'));
|
||||
|
||||
const group = await groups.create('board-1', {
|
||||
name: 'Test',
|
||||
color: '#FF5733',
|
||||
image_ids: ['img1'],
|
||||
});
|
||||
|
||||
expect(group).toBeNull();
|
||||
expect(get(groupsError)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deletes group', async () => {
|
||||
// First create a group
|
||||
await groups.create('board-1', {
|
||||
name: 'Test',
|
||||
color: '#FF5733',
|
||||
image_ids: ['img1'],
|
||||
});
|
||||
|
||||
expect(get(groupCount)).toBe(1);
|
||||
|
||||
// Then delete it
|
||||
await groups.delete('board-1', 'group-1');
|
||||
|
||||
expect(get(groupCount)).toBe(0);
|
||||
});
|
||||
|
||||
it('clears all groups', () => {
|
||||
groups.clear();
|
||||
|
||||
const state = get(groups);
|
||||
expect(state.groups).toEqual([]);
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ungroup Operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('ungroups images', async () => {
|
||||
const result = await ungroupImages('board-1', 'group-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('handles ungroup error', async () => {
|
||||
const { deleteGroup } = await import('$lib/api/groups');
|
||||
vi.mocked(deleteGroup).mockRejectedValueOnce(new Error('API Error'));
|
||||
|
||||
const result = await ungroupImages('board-1', 'group-1');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('removes specific images from group', async () => {
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
apiClient: {
|
||||
patch: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await removeImagesFromGroup('board-1', 'group-1', ['img1', 'img2']);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user