feat: manage fruits — full CRUD with images and synonyms (story #02)
Backend: domain structs, FruitRepository interface + pg implementation,
9 Echo v4 handlers (list/get/create/update/delete, image sub-resources),
migration 000002 (fruit_type ENUM, fruits, fruit_synonyms, fruit_images),
route-scoped BodyLimit("5M") for uploads, http.DetectContentType for serving.
Frontend: typed fetch API layer, Pinia setup-style fruitStore, FruitList
(paginated), FruitCreate, and FruitDetail (edit + synonyms editor + image
gallery). 25 backend unit tests + integration test; 18 frontend tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
listFruits,
|
||||
getFruit,
|
||||
createFruit,
|
||||
updateFruit,
|
||||
deleteFruit,
|
||||
addImage,
|
||||
deleteImage,
|
||||
imageUrl,
|
||||
} from './fruits'
|
||||
|
||||
type MockResponse = {
|
||||
ok: boolean
|
||||
status: number
|
||||
json: () => Promise<unknown>
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => {
|
||||
const method = init?.method ?? 'GET'
|
||||
|
||||
if (url === '/api/v1/fruits?limit=50&offset=0' && method === 'GET') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits?limit=10&offset=20' && method === 'GET') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [], total: 0, limit: 10, offset: 20 }),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits/1' && method === 'GET') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ id: 1, name: 'Boskop', synonyms: [], images: [] }),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits/999' && method === 'GET') {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: 'not found' }),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits' && method === 'POST') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ id: 2, name: 'Golden Delicious', synonyms: [], images: [] }),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits/1' && method === 'PUT') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ id: 1, name: 'Updated', synonyms: [], images: [] }),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits/1' && method === 'DELETE') {
|
||||
return Promise.resolve({ ok: true, status: 204, json: async () => null })
|
||||
}
|
||||
if (url === '/api/v1/fruits/1/images' && method === 'POST') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ id: 10, fruit_id: 1, image_type: 'fruit', url: '/api/v1/fruits/1/images/10' }),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits/1/images/10' && method === 'DELETE') {
|
||||
return Promise.resolve({ ok: true, status: 204, json: async () => null })
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${method} ${url}`))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('imageUrl', () => {
|
||||
it('builds the correct URL', () => {
|
||||
expect(imageUrl(1, 10)).toBe('/api/v1/fruits/1/images/10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listFruits', () => {
|
||||
it('calls GET /api/v1/fruits with default params', async () => {
|
||||
const result = await listFruits()
|
||||
expect(result.items).toEqual([])
|
||||
expect(result.limit).toBe(50)
|
||||
})
|
||||
|
||||
it('passes custom limit and offset', async () => {
|
||||
const result = await listFruits(10, 20)
|
||||
expect(result.offset).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFruit', () => {
|
||||
it('returns the fruit', async () => {
|
||||
const f = await getFruit(1)
|
||||
expect(f.id).toBe(1)
|
||||
expect(f.name).toBe('Boskop')
|
||||
})
|
||||
|
||||
it('throws on 404', async () => {
|
||||
await expect(getFruit(999)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createFruit', () => {
|
||||
it('POSTs and returns created fruit', async () => {
|
||||
const f = await createFruit({ name: 'Golden Delicious', osdb_number: 'G001', fruit_type: 'Apfelsorten', synonyms: [] })
|
||||
expect(f.id).toBe(2)
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/fruits', expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateFruit', () => {
|
||||
it('PUTs and returns updated fruit', async () => {
|
||||
const f = await updateFruit(1, { name: 'Updated', osdb_number: 'A001', fruit_type: 'Apfelsorten', synonyms: [] })
|
||||
expect(f.name).toBe('Updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteFruit', () => {
|
||||
it('DELETEs without error on 204', async () => {
|
||||
await expect(deleteFruit(1)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('addImage', () => {
|
||||
it('POSTs FormData without explicit Content-Type', async () => {
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'test.png', { type: 'image/png' })
|
||||
const img = await addImage(1, file, 'fruit', 'A test')
|
||||
expect(img.id).toBe(10)
|
||||
const call = fetchMock.mock.calls.find(([u, i]) => u === '/api/v1/fruits/1/images' && i?.method === 'POST')
|
||||
expect(call).toBeDefined()
|
||||
const init = call![1] as RequestInit
|
||||
// No Content-Type header set manually — body is FormData (browser sets boundary)
|
||||
const headers = (init.headers ?? {}) as Record<string, string>
|
||||
expect(headers['Content-Type']).toBeUndefined()
|
||||
expect(init.body).toBeInstanceOf(FormData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteImage', () => {
|
||||
it('DELETEs the image', async () => {
|
||||
await expect(deleteImage(1, 10)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
export const FRUIT_TYPES = [
|
||||
'Apfelsorten',
|
||||
'Birnensorten',
|
||||
'Quittensorten',
|
||||
'Aprikosen',
|
||||
'Pfirsiche',
|
||||
'Mirabellen',
|
||||
'Renekloden',
|
||||
'Pflaumen',
|
||||
'Zwetschen',
|
||||
'Sauerkirschen',
|
||||
'Süßkirschen',
|
||||
'Brombeeren',
|
||||
'Erdbeeren',
|
||||
'Himbeeren',
|
||||
'Johannisbeeren',
|
||||
'Stachelbeeren',
|
||||
'Wein',
|
||||
] as const
|
||||
|
||||
export type FruitType = (typeof FRUIT_TYPES)[number]
|
||||
|
||||
export interface FruitImage {
|
||||
id: number
|
||||
fruit_id: number
|
||||
filename: string | null
|
||||
image_type: string
|
||||
title: string | null
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Fruit {
|
||||
id: number
|
||||
name: string
|
||||
osdb_number: string
|
||||
comment: string | null
|
||||
fruit_type: string
|
||||
synonyms: string[]
|
||||
images: FruitImage[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FruitListResponse {
|
||||
items: Fruit[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface FruitWriteDTO {
|
||||
name: string
|
||||
osdb_number: string
|
||||
comment?: string | null
|
||||
fruit_type: string
|
||||
synonyms: string[]
|
||||
}
|
||||
|
||||
export function imageUrl(fruitId: number, imageId: number): string {
|
||||
return `/api/v1/fruits/${fruitId}/images/${imageId}`
|
||||
}
|
||||
|
||||
async function checkOk(res: Response): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
msg = body.error ?? body.errors?.join(', ') ?? msg
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
const err = new Error(msg) as Error & { status: number }
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
export async function listFruits(limit = 50, offset = 0): Promise<FruitListResponse> {
|
||||
const res = await fetch(`/api/v1/fruits?limit=${limit}&offset=${offset}`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function getFruit(id: number): Promise<Fruit> {
|
||||
const res = await fetch(`/api/v1/fruits/${id}`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function createFruit(dto: FruitWriteDTO): Promise<Fruit> {
|
||||
const res = await fetch('/api/v1/fruits', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function updateFruit(id: number, dto: FruitWriteDTO): Promise<Fruit> {
|
||||
const res = await fetch(`/api/v1/fruits/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteFruit(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function listImages(fruitId: number): Promise<FruitImage[]> {
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function addImage(
|
||||
fruitId: number,
|
||||
file: File,
|
||||
imageType: string,
|
||||
title?: string,
|
||||
): Promise<FruitImage> {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
form.append('image_type', imageType)
|
||||
if (title) form.append('title', title)
|
||||
// Do NOT set Content-Type — browser sets it with the correct multipart boundary
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images`, { method: 'POST', body: form })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteImage(fruitId: number, imageId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HelloWorld from '../views/HelloWorld.vue'
|
||||
import FruitList from '../views/FruitList.vue'
|
||||
import FruitCreate from '../views/FruitCreate.vue'
|
||||
import FruitDetail from '../views/FruitDetail.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -8,6 +11,20 @@ const router = createRouter({
|
||||
path: '/',
|
||||
component: HelloWorld,
|
||||
},
|
||||
{
|
||||
path: '/fruits',
|
||||
component: FruitList,
|
||||
},
|
||||
{
|
||||
// /fruits/new must come before /:id so vue-router v5 doesn't capture "new" as a param
|
||||
path: '/fruits/new',
|
||||
component: FruitCreate,
|
||||
},
|
||||
{
|
||||
path: '/fruits/:id',
|
||||
component: FruitDetail,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useFruitStore } from './fruitStore'
|
||||
import type { Fruit } from '../api/fruits'
|
||||
|
||||
const makeFruit = (id: number, name = 'Boskop'): Fruit => ({
|
||||
id,
|
||||
name,
|
||||
osdb_number: `A00${id}`,
|
||||
comment: null,
|
||||
fruit_type: 'Apfelsorten',
|
||||
synonyms: [],
|
||||
images: [],
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||
const method = init?.method ?? 'GET'
|
||||
if (url.startsWith('/api/v1/fruits?') && method === 'GET') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [makeFruit(1), makeFruit(2)], total: 2, limit: 50, offset: 0 }),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits/1' && method === 'GET') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => makeFruit(1, 'Boskop Detail'),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits' && method === 'POST') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => makeFruit(3, 'New Fruit'),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits/1' && method === 'PUT') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => makeFruit(1, 'Updated Boskop'),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits/1' && method === 'DELETE') {
|
||||
return Promise.resolve({ ok: true, status: 204, json: async () => null })
|
||||
}
|
||||
if (url === '/api/v1/fruits/999' && method === 'GET') {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: 'not found' }),
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected: ${method} ${url}`))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('fruitStore', () => {
|
||||
it('fetchFruits populates state', async () => {
|
||||
const store = useFruitStore()
|
||||
await store.fetchFruits()
|
||||
expect(store.fruits).toHaveLength(2)
|
||||
expect(store.total).toBe(2)
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('fetchFruit sets current', async () => {
|
||||
const store = useFruitStore()
|
||||
await store.fetchFruit(1)
|
||||
expect(store.current?.name).toBe('Boskop Detail')
|
||||
})
|
||||
|
||||
it('fetchFruit error sets error state', async () => {
|
||||
const store = useFruitStore()
|
||||
await store.fetchFruit(999)
|
||||
expect(store.error).toBeTruthy()
|
||||
expect(store.current).toBeNull()
|
||||
})
|
||||
|
||||
it('create prepends fruit and increments total', async () => {
|
||||
const store = useFruitStore()
|
||||
await store.fetchFruits()
|
||||
const before = store.fruits.length
|
||||
const before_total = store.total
|
||||
await store.create({ name: 'New', osdb_number: 'N001', fruit_type: 'Apfelsorten', synonyms: [] })
|
||||
expect(store.fruits[0].name).toBe('New Fruit')
|
||||
expect(store.fruits.length).toBe(before + 1)
|
||||
expect(store.total).toBe(before_total + 1)
|
||||
})
|
||||
|
||||
it('update replaces fruit in list', async () => {
|
||||
const store = useFruitStore()
|
||||
await store.fetchFruits()
|
||||
await store.update(1, { name: 'Updated Boskop', osdb_number: 'A001', fruit_type: 'Apfelsorten', synonyms: [] })
|
||||
const f = store.fruits.find((x) => x.id === 1)
|
||||
expect(f?.name).toBe('Updated Boskop')
|
||||
})
|
||||
|
||||
it('remove filters fruit from list', async () => {
|
||||
const store = useFruitStore()
|
||||
await store.fetchFruits()
|
||||
await store.remove(1)
|
||||
expect(store.fruits.find((f) => f.id === 1)).toBeUndefined()
|
||||
expect(store.total).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
listFruits,
|
||||
getFruit,
|
||||
createFruit,
|
||||
updateFruit,
|
||||
deleteFruit,
|
||||
type Fruit,
|
||||
type FruitWriteDTO,
|
||||
} from '../api/fruits'
|
||||
|
||||
export const useFruitStore = defineStore('fruit', () => {
|
||||
const fruits = ref<Fruit[]>([])
|
||||
const total = ref(0)
|
||||
const limit = ref(50)
|
||||
const offset = ref(0)
|
||||
const current = ref<Fruit | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchFruits(lim = limit.value, off = offset.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await listFruits(lim, off)
|
||||
fruits.value = resp.items
|
||||
total.value = resp.total
|
||||
limit.value = resp.limit
|
||||
offset.value = resp.offset
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'unknown error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFruit(id: number) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
current.value = await getFruit(id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'unknown error'
|
||||
current.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(dto: FruitWriteDTO): Promise<Fruit> {
|
||||
const fruit = await createFruit(dto)
|
||||
fruits.value = [fruit, ...fruits.value]
|
||||
total.value += 1
|
||||
return fruit
|
||||
}
|
||||
|
||||
async function update(id: number, dto: FruitWriteDTO): Promise<Fruit> {
|
||||
const fruit = await updateFruit(id, dto)
|
||||
const idx = fruits.value.findIndex((f) => f.id === id)
|
||||
if (idx !== -1) fruits.value[idx] = fruit
|
||||
if (current.value?.id === id) current.value = fruit
|
||||
return fruit
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
await deleteFruit(id)
|
||||
fruits.value = fruits.value.filter((f) => f.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
if (current.value?.id === id) current.value = null
|
||||
}
|
||||
|
||||
return { fruits, total, limit, offset, current, loading, error, fetchFruits, fetchFruit, create, update, remove }
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useFruitStore } from '../stores/fruitStore'
|
||||
import { FRUIT_TYPES } from '../api/fruits'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useFruitStore()
|
||||
|
||||
const name = ref('')
|
||||
const osdbNumber = ref('')
|
||||
const comment = ref('')
|
||||
const fruitType = ref('')
|
||||
const synonymInput = ref('')
|
||||
const synonyms = ref<string[]>([])
|
||||
const submitting = ref(false)
|
||||
const serverError = ref<string | null>(null)
|
||||
|
||||
function addSynonym() {
|
||||
const s = synonymInput.value.trim()
|
||||
if (s && !synonyms.value.includes(s)) {
|
||||
synonyms.value.push(s)
|
||||
}
|
||||
synonymInput.value = ''
|
||||
}
|
||||
|
||||
function removeSynonym(idx: number) {
|
||||
synonyms.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
serverError.value = null
|
||||
submitting.value = true
|
||||
try {
|
||||
const fruit = await store.create({
|
||||
name: name.value,
|
||||
osdb_number: osdbNumber.value,
|
||||
comment: comment.value || null,
|
||||
fruit_type: fruitType.value,
|
||||
synonyms: synonyms.value,
|
||||
})
|
||||
router.push(`/fruits/${fruit.id}`)
|
||||
} catch (e) {
|
||||
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">Neue Frucht anlegen</h1>
|
||||
|
||||
<div v-if="serverError" class="mb-4 p-3 bg-red-50 border border-red-300 rounded text-red-700 text-sm">
|
||||
{{ serverError }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input v-model="name" type="text" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">OSDB-Kürzel *</label>
|
||||
<input v-model="osdbNumber" type="text" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Typ *</label>
|
||||
<select v-model="fruitType" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500">
|
||||
<option value="" disabled>Bitte wählen...</option>
|
||||
<option v-for="t in FRUIT_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Kommentar</label>
|
||||
<textarea v-model="comment" rows="3" class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Synonyme</label>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input
|
||||
v-model="synonymInput"
|
||||
type="text"
|
||||
@keydown.enter.prevent="addSynonym"
|
||||
placeholder="Synonym eingeben und Enter drücken"
|
||||
class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
<button type="button" @click="addSynonym" class="bg-gray-200 px-3 py-2 rounded hover:bg-gray-300">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="(s, i) in synonyms" :key="i" class="flex items-center gap-2 text-sm">
|
||||
<span class="flex-1 bg-gray-100 rounded px-2 py-1">{{ s }}</span>
|
||||
<button type="button" @click="removeSynonym(i)" class="text-red-500 hover:text-red-700 text-xs">✕</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="bg-green-600 text-white px-6 py-2 rounded hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{{ submitting ? 'Speichern...' : 'Anlegen' }}
|
||||
</button>
|
||||
<RouterLink to="/fruits" class="px-6 py-2 border rounded hover:bg-gray-50 transition-colors">
|
||||
Abbrechen
|
||||
</RouterLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,266 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useFruitStore } from '../stores/fruitStore'
|
||||
import { addImage, deleteImage, FRUIT_TYPES } from '../api/fruits'
|
||||
import type { Fruit } from '../api/fruits'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const store = useFruitStore()
|
||||
|
||||
const editing = ref(false)
|
||||
const editName = ref('')
|
||||
const editOsdbNumber = ref('')
|
||||
const editComment = ref('')
|
||||
const editFruitType = ref('')
|
||||
const editSynonyms = ref<string[]>([])
|
||||
const synonymInput = ref('')
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const serverError = ref<string | null>(null)
|
||||
|
||||
const imageFile = ref<File | null>(null)
|
||||
const imageType = ref('fruit')
|
||||
const imageTitle = ref('')
|
||||
const uploading = ref(false)
|
||||
const uploadError = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchFruit(Number(props.id))
|
||||
if (store.current) startEdit(store.current)
|
||||
})
|
||||
|
||||
function startEdit(f: Fruit) {
|
||||
editName.value = f.name
|
||||
editOsdbNumber.value = f.osdb_number
|
||||
editComment.value = f.comment ?? ''
|
||||
editFruitType.value = f.fruit_type
|
||||
editSynonyms.value = [...f.synonyms]
|
||||
}
|
||||
|
||||
function addSynonym() {
|
||||
const s = synonymInput.value.trim()
|
||||
if (s && !editSynonyms.value.includes(s)) editSynonyms.value.push(s)
|
||||
synonymInput.value = ''
|
||||
}
|
||||
|
||||
function removeSynonym(idx: number) {
|
||||
editSynonyms.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!store.current) return
|
||||
serverError.value = null
|
||||
saving.value = true
|
||||
try {
|
||||
const updated = await store.update(Number(props.id), {
|
||||
name: editName.value,
|
||||
osdb_number: editOsdbNumber.value,
|
||||
comment: editComment.value || null,
|
||||
fruit_type: editFruitType.value,
|
||||
synonyms: editSynonyms.value,
|
||||
})
|
||||
startEdit(updated)
|
||||
editing.value = false
|
||||
} catch (e) {
|
||||
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm('Frucht wirklich löschen?')) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await store.remove(Number(props.id))
|
||||
router.push('/fruits')
|
||||
} catch (e) {
|
||||
serverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
imageFile.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
async function uploadImage() {
|
||||
if (!imageFile.value) return
|
||||
uploadError.value = null
|
||||
uploading.value = true
|
||||
try {
|
||||
await addImage(Number(props.id), imageFile.value, imageType.value, imageTitle.value || undefined)
|
||||
imageFile.value = null
|
||||
imageTitle.value = ''
|
||||
await store.fetchFruit(Number(props.id))
|
||||
} catch (e) {
|
||||
uploadError.value = e instanceof Error ? e.message : 'Upload fehlgeschlagen'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeImage(imageId: number) {
|
||||
if (!confirm('Bild wirklich löschen?')) return
|
||||
try {
|
||||
await deleteImage(Number(props.id), imageId)
|
||||
await store.fetchFruit(Number(props.id))
|
||||
} catch (e) {
|
||||
serverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen des Bildes'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-3xl mx-auto">
|
||||
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div>
|
||||
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
|
||||
<div v-else-if="!store.current" class="text-gray-400">Frucht nicht gefunden.</div>
|
||||
<div v-else>
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ store.current.name }}</h1>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="!editing"
|
||||
@click="editing = true"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors text-sm"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
@click="remove"
|
||||
:disabled="deleting"
|
||||
class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 disabled:opacity-50 transition-colors text-sm"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="serverError" class="mb-4 p-3 bg-red-50 border border-red-300 rounded text-red-700 text-sm">
|
||||
{{ serverError }}
|
||||
</div>
|
||||
|
||||
<!-- View / Edit form -->
|
||||
<div class="space-y-4">
|
||||
<div v-if="!editing" class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">OSDB-Kürzel:</span>
|
||||
<span class="ml-2 text-gray-900">{{ store.current.osdb_number }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-gray-700">Typ:</span>
|
||||
<span class="ml-2 text-gray-900">{{ store.current.fruit_type }}</span>
|
||||
</div>
|
||||
<div v-if="store.current.comment" class="col-span-2">
|
||||
<span class="font-medium text-gray-700">Kommentar:</span>
|
||||
<span class="ml-2 text-gray-900">{{ store.current.comment }}</span>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span class="font-medium text-gray-700">Synonyme:</span>
|
||||
<span v-if="store.current.synonyms.length === 0" class="ml-2 text-gray-400">keine</span>
|
||||
<span v-else class="ml-2 text-gray-900">{{ store.current.synonyms.join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="save" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input v-model="editName" type="text" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">OSDB-Kürzel *</label>
|
||||
<input v-model="editOsdbNumber" type="text" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Typ *</label>
|
||||
<select v-model="editFruitType" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500">
|
||||
<option v-for="t in FRUIT_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Kommentar</label>
|
||||
<textarea v-model="editComment" rows="3" class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Synonyme</label>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input v-model="synonymInput" type="text" @keydown.enter.prevent="addSynonym" placeholder="Synonym hinzufügen" class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
|
||||
<button type="button" @click="addSynonym" class="bg-gray-200 px-3 py-2 rounded hover:bg-gray-300">+</button>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="(s, i) in editSynonyms" :key="i" class="flex items-center gap-2 text-sm">
|
||||
<span class="flex-1 bg-gray-100 rounded px-2 py-1">{{ s }}</span>
|
||||
<button type="button" @click="removeSynonym(i)" class="text-red-500 hover:text-red-700 text-xs">✕</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" :disabled="saving" class="bg-green-600 text-white px-6 py-2 rounded hover:bg-green-700 disabled:opacity-50 transition-colors">
|
||||
{{ saving ? 'Speichern...' : 'Speichern' }}
|
||||
</button>
|
||||
<button type="button" @click="editing = false" class="px-6 py-2 border rounded hover:bg-gray-50 transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Image gallery -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">Bilder</h2>
|
||||
|
||||
<div v-if="store.current.images.length === 0" class="text-gray-400 text-sm mb-4">Keine Bilder vorhanden.</div>
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
||||
<div v-for="img in store.current.images" :key="img.id" class="relative group border rounded overflow-hidden">
|
||||
<img :src="img.url" :alt="img.title ?? img.image_type" class="w-full h-32 object-cover" />
|
||||
<div class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@click="removeImage(img.id)"
|
||||
class="bg-red-600 text-white rounded-full w-6 h-6 text-xs flex items-center justify-center hover:bg-red-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-1 text-xs text-gray-500">{{ img.image_type }}{{ img.title ? ' · ' + img.title : '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload form -->
|
||||
<div class="border rounded p-4 bg-gray-50">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">Bild hochladen</h3>
|
||||
<div v-if="uploadError" class="mb-3 text-red-600 text-sm">{{ uploadError }}</div>
|
||||
<div class="space-y-3">
|
||||
<input type="file" accept="image/*" @change="onFileChange" class="block w-full text-sm text-gray-600" />
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Bildtyp *</label>
|
||||
<select v-model="imageType" class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500">
|
||||
<option value="fruit">Frucht</option>
|
||||
<option value="flower">Blüte</option>
|
||||
<option value="tree">Baum</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Titel (optional)</label>
|
||||
<input v-model="imageTitle" type="text" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-full focus:outline-none focus:ring-2 focus:ring-green-500" />
|
||||
</div>
|
||||
<button
|
||||
@click="uploadImage"
|
||||
:disabled="!imageFile || uploading"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded text-sm hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{{ uploading ? 'Hochladen...' : 'Hochladen' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<RouterLink to="/fruits" class="text-green-700 hover:underline text-sm">← Zurück zur Liste</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useFruitStore } from '../stores/fruitStore'
|
||||
|
||||
const store = useFruitStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchFruits()
|
||||
})
|
||||
|
||||
const hasPrev = computed(() => store.offset > 0)
|
||||
const hasNext = computed(() => store.offset + store.limit < store.total)
|
||||
|
||||
async function prev() {
|
||||
await store.fetchFruits(store.limit, Math.max(0, store.offset - store.limit))
|
||||
}
|
||||
|
||||
async function next() {
|
||||
await store.fetchFruits(store.limit, store.offset + store.limit)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Früchte</h1>
|
||||
<RouterLink
|
||||
to="/fruits/new"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Neue Frucht
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Search placeholder (story #06) -->
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche... (kommt in Story #06)"
|
||||
disabled
|
||||
class="w-full border border-gray-300 rounded px-3 py-2 bg-gray-100 text-gray-400 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div>
|
||||
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
|
||||
<div v-else>
|
||||
<table class="w-full border-collapse border border-gray-200 rounded">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Name</th>
|
||||
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">OSDB-Kürzel</th>
|
||||
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Typ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="store.fruits.length === 0">
|
||||
<td colspan="3" class="border border-gray-200 px-4 py-8 text-center text-gray-400">
|
||||
Keine Früchte vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="fruit in store.fruits"
|
||||
:key="fruit.id"
|
||||
class="hover:bg-gray-50 cursor-pointer"
|
||||
>
|
||||
<td class="border border-gray-200 px-4 py-2">
|
||||
<RouterLink :to="`/fruits/${fruit.id}`" class="text-green-700 hover:underline">
|
||||
{{ fruit.name }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td class="border border-gray-200 px-4 py-2 text-sm text-gray-600">{{ fruit.osdb_number }}</td>
|
||||
<td class="border border-gray-200 px-4 py-2 text-sm text-gray-600">{{ fruit.fruit_type }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
|
||||
<span>{{ store.total }} Früchte gesamt</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="prev"
|
||||
:disabled="!hasPrev"
|
||||
class="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
← Zurück
|
||||
</button>
|
||||
<button
|
||||
@click="next"
|
||||
:disabled="!hasNext"
|
||||
class="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user