feat: media page T1
This commit is contained in:
1 parent
67d456baf6
commit
008b0dfbb9
46 files changed
+5742
-204
No files matched your search
@@ -79,6 +79,27 @@ if (typeof window !== 'undefined') {
|
||||
})
|
||||
}
|
||||
|
||||
export function redirectToLogin() {
|
||||
const currentPath = router.currentRoute.value.fullPath
|
||||
useSessionStore().clear()
|
||||
authRedirectPending.value = true
|
||||
router
|
||||
.push({
|
||||
path: '/login',
|
||||
query: { redirect: currentPath, reason: 'expired' },
|
||||
})
|
||||
.finally(() => {
|
||||
authRedirectPending.value = false
|
||||
})
|
||||
}
|
||||
|
||||
export function redirectUnverified(onForbidden: OnForbidden = 'redirect') {
|
||||
const session = useSessionStore()
|
||||
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
|
||||
router.push('/register/pending')
|
||||
}
|
||||
}
|
||||
|
||||
export function useApi() {
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
|
||||
@@ -95,25 +116,12 @@ export function useApi() {
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && onUnauthorized === 'redirect') {
|
||||
const currentPath = router.currentRoute.value.fullPath
|
||||
useSessionStore().clear()
|
||||
authRedirectPending.value = true
|
||||
router
|
||||
.push({
|
||||
path: '/login',
|
||||
query: { redirect: currentPath, reason: 'expired' },
|
||||
})
|
||||
.finally(() => {
|
||||
authRedirectPending.value = false
|
||||
})
|
||||
redirectToLogin()
|
||||
throw new ApiError(401)
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
const session = useSessionStore()
|
||||
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
|
||||
router.push('/register/pending')
|
||||
}
|
||||
redirectUnverified(onForbidden)
|
||||
throw new ApiError(403)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,17 @@ export interface MediaUsageItem {
|
||||
published: boolean
|
||||
}
|
||||
|
||||
export interface MediaMergeResult {
|
||||
articles_updated: number
|
||||
merged: number
|
||||
}
|
||||
|
||||
export interface BulkDeleteResult {
|
||||
deleted: MediaItem[]
|
||||
failed: MediaItem[]
|
||||
handled: boolean
|
||||
}
|
||||
|
||||
interface MediaListResponse {
|
||||
items: MediaItem[]
|
||||
next_cursor: string | null
|
||||
@@ -142,6 +153,40 @@ export function useMedia() {
|
||||
removeLocal(id)
|
||||
}
|
||||
|
||||
async function deleteMany(targets: MediaItem[]): Promise<BulkDeleteResult> {
|
||||
const outcomes = await Promise.all(
|
||||
targets.map(async (item) => {
|
||||
try {
|
||||
await request<void>(`/v1/media/${item.id}`, { method: 'DELETE' })
|
||||
return { item, outcome: 'deleted' as const }
|
||||
} catch (error) {
|
||||
const failure = classifyMediaError(error)
|
||||
return {
|
||||
item,
|
||||
outcome: failure === 'not-found' ? ('deleted' as const) : failure,
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
const pick = (outcome: string) =>
|
||||
outcomes.filter((entry) => entry.outcome === outcome).map((entry) => entry.item)
|
||||
const deleted = pick('deleted')
|
||||
items.value = items.value.filter((entry) => !deleted.some((gone) => gone.id === entry.id))
|
||||
return { deleted, failed: pick('failed'), handled: pick('handled').length > 0 }
|
||||
}
|
||||
|
||||
function mergeCopies(keepId: number, mergeIds: number[]) {
|
||||
return request<MediaMergeResult>('/v1/media/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_id: keepId, merge_ids: mergeIds }),
|
||||
})
|
||||
}
|
||||
|
||||
function removeLocalMany(ids: number[]) {
|
||||
items.value = items.value.filter((entry) => !ids.includes(entry.id))
|
||||
}
|
||||
|
||||
function copyUrl(item: MediaItem) {
|
||||
return navigator.clipboard.writeText(`${window.location.origin}${item.url}`)
|
||||
}
|
||||
@@ -159,6 +204,9 @@ export function useMedia() {
|
||||
renameItem,
|
||||
deleteItem,
|
||||
removeLocal,
|
||||
removeLocalMany,
|
||||
deleteMany,
|
||||
mergeCopies,
|
||||
copyUrl,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
|
||||
export type SelectAllState = 'none' | 'partial' | 'all'
|
||||
|
||||
export function useMediaSelection(items: Ref<MediaItem[]>) {
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
const count = computed(() => selectedIds.value.length)
|
||||
const selectedItems = computed(() =>
|
||||
items.value.filter((item) => selectedIds.value.includes(item.id)),
|
||||
)
|
||||
const allState = computed<SelectAllState>(() => {
|
||||
if (count.value === 0) {
|
||||
return 'none'
|
||||
}
|
||||
return count.value >= items.value.length ? 'all' : 'partial'
|
||||
})
|
||||
|
||||
function isSelected(id: number) {
|
||||
return selectedIds.value.includes(id)
|
||||
}
|
||||
|
||||
function select(id: number) {
|
||||
if (!isSelected(id)) {
|
||||
selectedIds.value = [...selectedIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: number) {
|
||||
selectedIds.value = isSelected(id)
|
||||
? selectedIds.value.filter((entry) => entry !== id)
|
||||
: [...selectedIds.value, id]
|
||||
}
|
||||
|
||||
function replace(ids: number[]) {
|
||||
selectedIds.value = ids
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectedIds.value = []
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
selectedIds.value = allState.value === 'all' ? [] : items.value.map((item) => item.id)
|
||||
}
|
||||
|
||||
watch(items, (loaded) => {
|
||||
const kept = selectedIds.value.filter((id) => loaded.some((item) => item.id === id))
|
||||
if (kept.length !== selectedIds.value.length) {
|
||||
selectedIds.value = kept
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
selectedItems,
|
||||
count,
|
||||
allState,
|
||||
isSelected,
|
||||
select,
|
||||
toggle,
|
||||
replace,
|
||||
clear,
|
||||
toggleAll,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { redirectToLogin, redirectUnverified, useApi } from '@/composables/useApi'
|
||||
import { classifyMediaError, type MediaItem } from '@/composables/useMedia'
|
||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||
import { hashFile } from '@/lib/fileHash'
|
||||
import { extensionMatchesType, validateImageFile } from '@/lib/imageUpload'
|
||||
import { describeUpload, parseRetryAfter } from '@/lib/mediaUploadRows'
|
||||
import type { UploadRejection, UploadRowStatus } from '@/lib/mediaUploadRows'
|
||||
|
||||
const MAX_CONCURRENT_UPLOADS = 2
|
||||
const UPLOAD_URL = '/v1/media/'
|
||||
const HTTP_CREATED = 201
|
||||
const HTTP_BAD_REQUEST = 400
|
||||
const HTTP_FORBIDDEN = 403
|
||||
const HTTP_UNAUTHORIZED = 401
|
||||
const HTTP_TOO_LARGE = 413
|
||||
const HTTP_UNSUPPORTED = 415
|
||||
const HTTP_RATE_LIMITED = 429
|
||||
const TONE_COUNT = 4
|
||||
|
||||
export interface UploadRow {
|
||||
key: number
|
||||
file: File
|
||||
name: string
|
||||
size: number
|
||||
status: UploadRowStatus
|
||||
loaded: number
|
||||
total: number
|
||||
rejection: UploadRejection | null
|
||||
existing: MediaItem | null
|
||||
previewUrl: string | null
|
||||
skipLookup: boolean
|
||||
tone: number
|
||||
}
|
||||
|
||||
interface UploadOptions {
|
||||
onAdded?: () => void
|
||||
}
|
||||
|
||||
function toneOf(name: string) {
|
||||
return Array.from(name).reduce((sum, letter) => sum + letter.charCodeAt(0), 0) % TONE_COUNT
|
||||
}
|
||||
|
||||
function oldestOf(items: MediaItem[]) {
|
||||
return [...items].sort(
|
||||
(first, second) =>
|
||||
Date.parse(first.created_at) - Date.parse(second.created_at) || first.id - second.id,
|
||||
)[0]
|
||||
}
|
||||
|
||||
function mismatchRejection(file: File): UploadRejection | null {
|
||||
return extensionMatchesType(file) ? null : 'type'
|
||||
}
|
||||
|
||||
function createPreview(file: File) {
|
||||
return typeof URL.createObjectURL === 'function' ? URL.createObjectURL(file) : null
|
||||
}
|
||||
|
||||
function releasePreview(row: UploadRow) {
|
||||
if (row.previewUrl && typeof URL.revokeObjectURL === 'function') {
|
||||
URL.revokeObjectURL(row.previewUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export function useMediaUpload(options: UploadOptions = {}) {
|
||||
const { request } = useApi()
|
||||
const retry = useRetryAfter()
|
||||
const rows = ref<UploadRow[]>([])
|
||||
const slots = new Set<number>()
|
||||
const requests = new Map<number, XMLHttpRequest>()
|
||||
let nextKey = 1
|
||||
let halted = false
|
||||
|
||||
const header = computed(() => describeUpload(rows.value))
|
||||
const limited = computed(() => retry.active.value)
|
||||
const countdown = computed(() => retry.formatted.value)
|
||||
|
||||
function createRow(file: File): UploadRow {
|
||||
const verdict = validateImageFile(file)
|
||||
const rejection = verdict.ok ? mismatchRejection(file) : verdict.kind
|
||||
return {
|
||||
key: nextKey++,
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: rejection ? 'rejected' : 'waiting',
|
||||
loaded: 0,
|
||||
total: file.size,
|
||||
rejection,
|
||||
existing: null,
|
||||
previewUrl: rejection ? null : createPreview(file),
|
||||
skipLookup: false,
|
||||
tone: toneOf(file.name),
|
||||
}
|
||||
}
|
||||
|
||||
function find(key: number) {
|
||||
return rows.value.find((row) => row.key === key)
|
||||
}
|
||||
|
||||
function release(key: number) {
|
||||
slots.delete(key)
|
||||
requests.delete(key)
|
||||
}
|
||||
|
||||
function settle(key: number) {
|
||||
release(key)
|
||||
pump()
|
||||
}
|
||||
|
||||
function halt(row: UploadRow) {
|
||||
row.status = 'waiting'
|
||||
release(row.key)
|
||||
halted = true
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (halted || limited.value) {
|
||||
return
|
||||
}
|
||||
for (const row of rows.value) {
|
||||
if (slots.size >= MAX_CONCURRENT_UPLOADS) {
|
||||
return
|
||||
}
|
||||
if (row.status === 'waiting' && !slots.has(row.key)) {
|
||||
slots.add(row.key)
|
||||
void process(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findExisting(file: File): Promise<MediaItem | null | 'halted'> {
|
||||
const hash = await hashFile(file)
|
||||
if (hash === null) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`)
|
||||
return oldestOf(response.items) ?? null
|
||||
} catch (error) {
|
||||
return classifyMediaError(error) === 'handled' ? 'halted' : null
|
||||
}
|
||||
}
|
||||
|
||||
async function process(row: UploadRow) {
|
||||
if (!row.skipLookup) {
|
||||
const match = await findExisting(row.file)
|
||||
if (!find(row.key)) {
|
||||
settle(row.key)
|
||||
return
|
||||
}
|
||||
if (match === 'halted') {
|
||||
halt(row)
|
||||
return
|
||||
}
|
||||
if (match) {
|
||||
row.status = 'duplicate'
|
||||
row.existing = match
|
||||
settle(row.key)
|
||||
return
|
||||
}
|
||||
}
|
||||
send(row)
|
||||
}
|
||||
|
||||
function reject(row: UploadRow, kind: UploadRejection) {
|
||||
row.status = 'rejected'
|
||||
row.rejection = kind
|
||||
settle(row.key)
|
||||
}
|
||||
|
||||
function pause(row: UploadRow, header: string | null) {
|
||||
row.status = 'waiting'
|
||||
release(row.key)
|
||||
retry.start(parseRetryAfter(header))
|
||||
}
|
||||
|
||||
function onLoaded(row: UploadRow, xhr: XMLHttpRequest) {
|
||||
requests.delete(row.key)
|
||||
switch (xhr.status) {
|
||||
case HTTP_CREATED:
|
||||
row.status = 'added'
|
||||
options.onAdded?.()
|
||||
settle(row.key)
|
||||
return
|
||||
case HTTP_BAD_REQUEST:
|
||||
reject(row, 'unreadable')
|
||||
return
|
||||
case HTTP_TOO_LARGE:
|
||||
reject(row, 'size')
|
||||
return
|
||||
case HTTP_UNSUPPORTED:
|
||||
reject(row, 'type')
|
||||
return
|
||||
case HTTP_UNAUTHORIZED:
|
||||
redirectToLogin()
|
||||
halt(row)
|
||||
return
|
||||
case HTTP_FORBIDDEN:
|
||||
redirectUnverified()
|
||||
halt(row)
|
||||
return
|
||||
case HTTP_RATE_LIMITED:
|
||||
pause(row, xhr.getResponseHeader('Retry-After'))
|
||||
return
|
||||
default:
|
||||
row.status = 'failed'
|
||||
settle(row.key)
|
||||
}
|
||||
}
|
||||
|
||||
function send(row: UploadRow) {
|
||||
row.status = 'uploading'
|
||||
row.loaded = 0
|
||||
row.total = row.size
|
||||
const xhr = new XMLHttpRequest()
|
||||
requests.set(row.key, xhr)
|
||||
xhr.open('POST', UPLOAD_URL)
|
||||
xhr.withCredentials = true
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
row.loaded = event.loaded
|
||||
row.total = event.total || row.size
|
||||
})
|
||||
xhr.addEventListener('load', () => onLoaded(row, xhr))
|
||||
xhr.addEventListener('error', () => {
|
||||
row.status = 'failed'
|
||||
settle(row.key)
|
||||
})
|
||||
const form = new FormData()
|
||||
form.append('file', row.file)
|
||||
xhr.send(form)
|
||||
}
|
||||
|
||||
function addFiles(files: File[]) {
|
||||
if (limited.value || files.length === 0) {
|
||||
return
|
||||
}
|
||||
rows.value.push(...files.map(createRow))
|
||||
pump()
|
||||
}
|
||||
|
||||
function remove(key: number) {
|
||||
const row = find(key)
|
||||
if (row) {
|
||||
releasePreview(row)
|
||||
}
|
||||
rows.value = rows.value.filter((entry) => entry.key !== key)
|
||||
}
|
||||
|
||||
function cancel(key: number) {
|
||||
requests.get(key)?.abort()
|
||||
release(key)
|
||||
remove(key)
|
||||
pump()
|
||||
}
|
||||
|
||||
function cancelAll() {
|
||||
for (const row of [...rows.value]) {
|
||||
if (row.status === 'uploading' || row.status === 'waiting') {
|
||||
requests.get(row.key)?.abort()
|
||||
release(row.key)
|
||||
remove(row.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retryRow(key: number) {
|
||||
const row = find(key)
|
||||
if (!row || (row.status !== 'failed' && row.status !== 'duplicate')) {
|
||||
return
|
||||
}
|
||||
row.status = 'waiting'
|
||||
row.skipLookup = true
|
||||
row.existing = null
|
||||
pump()
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
rows.value.forEach(releasePreview)
|
||||
rows.value = []
|
||||
}
|
||||
|
||||
watch(limited, (active) => {
|
||||
if (!active) {
|
||||
pump()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
requests.forEach((xhr) => xhr.abort())
|
||||
requests.clear()
|
||||
rows.value.forEach(releasePreview)
|
||||
})
|
||||
|
||||
return {
|
||||
rows,
|
||||
header,
|
||||
limited,
|
||||
countdown,
|
||||
addFiles,
|
||||
remove,
|
||||
cancel,
|
||||
cancelAll,
|
||||
retryRow,
|
||||
dismiss,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const toastsAboveBulkBar = ref(false)
|
||||
Reference in new issue
Block a user