feat: publication management with cover images, PDFs, and fruit images (story #04)
Full CRUD for publications; link fruits to publications; upload per-fruit PDF descriptions and fruit images stored as BYTEA; fruit detail page shows publication descriptions and images. ImageOverlayDrawer for cover thumbnail full-size view. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
export interface Publication {
|
||||
id: number
|
||||
title: string
|
||||
author: string | null
|
||||
osdb_pub_id: string
|
||||
image_url: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PublicationWriteDTO {
|
||||
title: string
|
||||
author?: string | null
|
||||
osdb_pub_id: string
|
||||
}
|
||||
|
||||
export interface PublicationFruit {
|
||||
fruit_id: number
|
||||
name: string
|
||||
osdb_number: string
|
||||
}
|
||||
|
||||
export interface PublicationDescription {
|
||||
id: number
|
||||
publication_id: number
|
||||
fruit_id: number
|
||||
fruit_name: string
|
||||
publication_title: string
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PublicationFruitImage {
|
||||
id: number
|
||||
publication_id: number
|
||||
publication_title: string
|
||||
publication_author: string | null
|
||||
fruit_id: number
|
||||
filename: string | null
|
||||
image_type: string
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function pubImageUrl(pubId: number): string {
|
||||
return `/api/v1/publications/${pubId}/image`
|
||||
}
|
||||
|
||||
export function pubDescriptionUrl(pubId: number, descId: number): string {
|
||||
return `/api/v1/publications/${pubId}/descriptions/${descId}`
|
||||
}
|
||||
|
||||
export function pubFruitImageUrl(pubId: number, imgId: number): string {
|
||||
return `/api/v1/publications/${pubId}/fruit-images/${imgId}`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
const err = new Error(msg) as Error & { status: number }
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
export async function listPublications(): Promise<Publication[]> {
|
||||
const res = await fetch('/api/v1/publications')
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function getPublication(id: number): Promise<Publication> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function createPublication(dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const res = await fetch('/api/v1/publications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function updatePublication(id: number, dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deletePublication(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function uploadCoverImage(pubId: number, file: File): Promise<void> {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'POST', body: form })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function deleteCoverImage(pubId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function listPubFruits(pubId: number): Promise<PublicationFruit[]> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function linkFruit(pubId: number, fruitId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fruit_id: fruitId }),
|
||||
})
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function unlinkFruit(pubId: number, fruitId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits/${fruitId}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function listDescriptions(pubId: number): Promise<PublicationDescription[]> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function uploadDescription(pubId: number, fruitId: number, file: File): Promise<PublicationDescription> {
|
||||
const form = new FormData()
|
||||
form.append('pdf', file)
|
||||
form.append('fruit_id', String(fruitId))
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`, { method: 'POST', body: form })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteDescription(pubId: number, descId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions/${descId}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function listPubFruitImages(pubId: number): Promise<PublicationFruitImage[]> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function uploadPubFruitImage(pubId: number, fruitId: number, file: File): Promise<PublicationFruitImage> {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
form.append('fruit_id', String(fruitId))
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`, { method: 'POST', body: form })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deletePubFruitImage(pubId: number, imgId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images/${imgId}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function getFruitDescriptions(fruitId: number): Promise<PublicationDescription[]> {
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/descriptions`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function getFruitPublicationImages(fruitId: number): Promise<PublicationFruitImage[]> {
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/publication-images`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
Reference in New Issue
Block a user