feat: media picker and image removal
This commit is contained in:
1 parent
008b0dfbb9
commit
b3793d5dbb
54 files changed
+3769
-1412
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user