feat: media picker and image removal

This commit is contained in:
fsylvain committed 2026-10-01 05:04:58 +02:00
1 parent 008b0dfbb9
commit b3793d5dbb
54 files changed
+3769 -1412

No files matched your search

-6
View File
@@ -1,15 +1,9 @@
import { useApi } from '@/composables/useApi'
export interface ArticleImage {
id: number
url: string
}
export interface Article {
id: number
title: string
body: string
images: ArticleImage[]
is_online: boolean
created_at: string
updated_at: string
+46 -17
View File
@@ -1,4 +1,14 @@
import { ref } from 'vue'
import { redirectToLogin, redirectUnverified } from '@/composables/useApi'
import type { MediaItem } from '@/composables/useMedia'
const UPLOAD_URL = '/v1/media/'
const HTTP_CREATED = 201
const HTTP_BAD_REQUEST = 400
const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403
const HTTP_TOO_LARGE = 413
const HTTP_UNSUPPORTED = 415
export interface UploadProgress {
loaded: number
@@ -6,10 +16,41 @@ export interface UploadProgress {
}
export type UploadOutcome =
| { kind: 'success'; location: string; id: number }
| { kind: 'rejected'; body: string }
| { kind: 'success'; item: MediaItem }
| { kind: 'rejected'; reason: 'size' | 'type' }
| { kind: 'handled' }
| { kind: 'failed' }
function readItem(responseText: string): MediaItem | null {
try {
return JSON.parse(responseText) as MediaItem
} catch {
return null
}
}
function outcomeOf(request: XMLHttpRequest): UploadOutcome {
switch (request.status) {
case HTTP_CREATED: {
const item = readItem(request.responseText)
return item ? { kind: 'success', item } : { kind: 'failed' }
}
case HTTP_TOO_LARGE:
return { kind: 'rejected', reason: 'size' }
case HTTP_UNSUPPORTED:
case HTTP_BAD_REQUEST:
return { kind: 'rejected', reason: 'type' }
case HTTP_UNAUTHORIZED:
redirectToLogin()
return { kind: 'handled' }
case HTTP_FORBIDDEN:
redirectUnverified()
return { kind: 'handled' }
default:
return { kind: 'failed' }
}
}
export function useImageUpload() {
const progress = ref<UploadProgress>({ loaded: 0, total: 0 })
let activeRequest: XMLHttpRequest | null = null
@@ -19,31 +60,19 @@ export function useImageUpload() {
activeRequest = null
}
function start(articleId: number, file: File): Promise<UploadOutcome> {
function start(file: File): Promise<UploadOutcome> {
progress.value = { loaded: 0, total: file.size }
return new Promise((resolve) => {
const request = new XMLHttpRequest()
activeRequest = request
request.open('POST', `/v1/article/${articleId}/image`)
request.open('POST', UPLOAD_URL)
request.withCredentials = true
request.upload.addEventListener('progress', (event) => {
progress.value = { loaded: event.loaded, total: event.total }
})
request.addEventListener('load', () => {
activeRequest = null
if (request.status === 201) {
const body = JSON.parse(request.responseText) as {
id: number
location: string
}
resolve({ kind: 'success', location: body.location, id: body.id })
return
}
if (request.status === 400) {
resolve({ kind: 'rejected', body: request.responseText })
return
}
resolve({ kind: 'failed' })
resolve(outcomeOf(request))
})
request.addEventListener('error', () => {
activeRequest = null
+171
View File
@@ -0,0 +1,171 @@
import { ref } from 'vue'
import { tryOnBeforeUnmount } from '@vueuse/core'
import { useApi } from '@/composables/useApi'
import {
MEDIA_SKELETON_DELAY_MS,
classifyMediaError,
type MediaCounts,
type MediaItem,
} from '@/composables/useMedia'
import { useSeenImagesStore } from '@/stores/seenImages'
export const LIBRARY_PAGE_LIMIT = 24
export type LibraryStatus = 'idle' | 'loading' | 'ready' | 'error' | 'handled'
interface LibraryResponse {
items: MediaItem[]
next_cursor: string | null
counts: MediaCounts
}
function requestQuery(query: string, cursor: string | null) {
return [
...(query ? [`q=${encodeURIComponent(query)}`] : []),
`limit=${LIBRARY_PAGE_LIMIT}`,
...(cursor ? [`cursor=${encodeURIComponent(cursor)}`] : []),
].join('&')
}
export function useLibraryList() {
const { request } = useApi()
const seen = useSeenImagesStore()
const items = ref<MediaItem[]>([])
const total = ref(0)
const nextCursor = ref<string | null>(null)
const status = ref<LibraryStatus>('idle')
const query = ref('')
const showSkeleton = ref(false)
const loadingMore = ref(false)
const firstNewId = ref<number | null>(null)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let latest = 0
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
function startSkeletonTimer() {
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, MEDIA_SKELETON_DELAY_MS)
}
function stopSkeleton() {
clearSkeletonTimer()
showSkeleton.value = false
}
function reset() {
latest += 1
items.value = []
total.value = 0
nextCursor.value = null
status.value = 'idle'
query.value = ''
loadingMore.value = false
firstNewId.value = null
stopSkeleton()
}
async function load(nextQuery = query.value) {
latest += 1
const ticket = latest
query.value = nextQuery
items.value = []
nextCursor.value = null
loadingMore.value = false
firstNewId.value = null
status.value = 'loading'
startSkeletonTimer()
try {
const response = await request<LibraryResponse>(
`/v1/media/?${requestQuery(nextQuery, null)}`,
)
if (ticket !== latest) {
return
}
items.value = response.items
total.value = response.counts.all
nextCursor.value = response.next_cursor
seen.remember(response.items)
status.value = 'ready'
} catch (error) {
if (ticket !== latest) {
return
}
status.value = classifyMediaError(error) === 'handled' ? 'handled' : 'error'
} finally {
if (ticket === latest) {
stopSkeleton()
}
}
}
async function loadMore() {
if (nextCursor.value === null || loadingMore.value || status.value !== 'ready') {
return
}
const ticket = latest
loadingMore.value = true
startSkeletonTimer()
try {
const response = await request<LibraryResponse>(
`/v1/media/?${requestQuery(query.value, nextCursor.value)}`,
)
if (ticket !== latest) {
return
}
const known = new Set(items.value.map((item) => item.id))
const added = response.items.filter((item) => !known.has(item.id))
items.value = [...items.value, ...added]
nextCursor.value = response.next_cursor
firstNewId.value = added[0]?.id ?? null
seen.remember(added)
} catch {
return
} finally {
if (ticket === latest) {
loadingMore.value = false
stopSkeleton()
}
}
}
async function search(nextQuery: string) {
if (nextQuery === query.value && status.value !== 'idle') {
return
}
await load(nextQuery)
}
async function ensureLoaded() {
if (status.value === 'idle') {
await load()
}
}
tryOnBeforeUnmount(clearSkeletonTimer)
return {
items,
total,
nextCursor,
status,
query,
showSkeleton,
loadingMore,
firstNewId,
load,
loadMore,
search,
ensureLoaded,
reset,
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ function toneOf(name: string) {
return Array.from(name).reduce((sum, letter) => sum + letter.charCodeAt(0), 0) % TONE_COUNT
}
function oldestOf(items: MediaItem[]) {
export function oldestOf(items: MediaItem[]) {
return [...items].sort(
(first, second) =>
Date.parse(first.created_at) - Date.parse(second.created_at) || first.id - second.id,