Add local avatar upload feature for author page

This commit is contained in:
江山 2025-09-30 05:52:17 +00:00
parent 03da194953
commit 4c80072f23
3 changed files with 208 additions and 87 deletions

View file

@ -1,6 +1,7 @@
<template> <template>
<div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden"> <div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden flex items-center justify-center">
<svg v-if="!imagePath" width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg"> <img v-if="imgSrc" :src="imgSrc" :alt="altText" class="w-full h-full object-cover" @load="imageLoaded" />
<svg v-else width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" /> <path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" /> <path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
<path <path
@ -13,10 +14,6 @@
/> />
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" /> <path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
</svg> </svg>
<div v-else class="w-full h-full relative">
<div v-if="showCoverBg" class="cover-bg absolute" :style="{ backgroundImage: `url(${imgSrc})` }" />
<img ref="img" :src="imgSrc" @load="imageLoaded" class="absolute top-0 left-0 h-full w-full" :class="coverContain ? 'object-contain' : 'object-cover'" />
</div>
</div> </div>
</template> </template>
@ -25,51 +22,71 @@ export default {
props: { props: {
author: { author: {
type: Object, type: Object,
default: () => {} default: () => ({})
}, },
rounded: { rounded: {
type: String, type: String,
default: 'lg' default: 'lg'
},
uploadedFile: {
type: [File, null],
default: null
},
uploadedUrl: {
type: String,
default: ''
},
altText: {
type: String,
default: 'author image'
} }
}, },
data() { data() {
return { return {
showCoverBg: false, objectUrl: null
coverContain: true
} }
}, },
computed: { computed: {
_author() {
return this.author || {}
},
authorId() {
return this._author.id
},
imagePath() { imagePath() {
return this._author.imagePath return this.author && this.author.imagePath
}, },
updatedAt() { updatedAt() {
return this._author.updatedAt return this.author && this.author.updatedAt
}, },
imgSrc() { imgSrc() {
if (!this.imagePath) return null // Prefer local file, then uploaded URL, then server image
return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?ts=${this.updatedAt}` if (this.objectUrl) return this.objectUrl
if (this.uploadedUrl) return this.uploadedUrl
if (this.imagePath) {
return `${this.$config.routerBasePath}/api/authors/${this.author.id}/image?ts=${this.updatedAt}`
}
return null
}
},
watch: {
uploadedFile: {
immediate: true,
handler(file) {
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl)
this.objectUrl = null
}
if (file && file instanceof File) {
this.objectUrl = URL.createObjectURL(file)
}
}
} }
}, },
methods: { methods: {
imageLoaded() { imageLoaded() {
if (this.$refs.img) { // Keep original functionality
var { naturalWidth, naturalHeight } = this.$refs.img
var imgAr = naturalHeight / naturalWidth
if (imgAr < 0.5 || imgAr > 2) {
this.showCoverBg = true
} else {
this.showCoverBg = false
this.coverContain = false
}
}
} }
}, },
mounted() {} beforeDestroy() {
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl)
this.objectUrl = null
}
}
} }
</script> </script>

View file

@ -9,16 +9,21 @@
<div class="flex"> <div class="flex">
<div class="w-40 p-2"> <div class="w-40 p-2">
<div class="w-full h-45 relative"> <div class="w-full h-45 relative">
<covers-author-image :author="authorCopy" /> <covers-author-image :author="authorCopy" :uploaded-file="selectedFile" :uploaded-url="previewUrl" />
<div v-if="userCanDelete && !processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100"> <div v-if="userCanDelete && !processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
<span class="absolute top-2 right-2 material-symbols text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span> <span class="absolute top-2 right-2 material-symbols text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
</div> </div>
</div> </div>
</div> </div>
<div class="grow"> <div class="grow">
<form @submit.prevent="submitUploadCover" class="flex grow mb-2 p-2"> <form @submit.prevent="submitUploadCover" class="flex grow mb-2 p-2 items-center">
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" /> <ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" @input="onUrlInput" />
<ui-btn color="bg-success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn> <label class="ml-2 sm:ml-3 w-24 h-9 flex items-center justify-center bg-gray-700 text-white rounded cursor-pointer hover:bg-gray-600">
<input type="file" accept="image/*" class="hidden" @change="onFileChange" />
Local
</label>
<ui-btn color="bg-success" type="submit" :padding-x="4" :disabled="!imageUrl && !selectedFile" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
<ui-btn v-if="selectedFile || previewUrl" type="button" color="bg-error" class="ml-2 sm:ml-3 w-24 h-9" small @click="clearSelection">{{ $strings.ButtonCancel }}</ui-btn>
</form> </form>
<form v-if="author" @submit.prevent="submitForm"> <form v-if="author" @submit.prevent="submitForm">
@ -38,7 +43,6 @@
<ui-btn v-if="userCanDelete" small color="bg-error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn> <ui-btn v-if="userCanDelete" small color="bg-error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
<div class="grow" /> <div class="grow" />
<ui-btn type="button" class="mx-2" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn> <ui-btn type="button" class="mx-2" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn> <ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div> </div>
</form> </form>
@ -58,6 +62,8 @@ export default {
description: '' description: ''
}, },
imageUrl: '', imageUrl: '',
selectedFile: null,
previewUrl: '',
processing: false processing: false
} }
}, },
@ -103,10 +109,36 @@ export default {
methods: { methods: {
init() { init() {
this.imageUrl = '' this.imageUrl = ''
this.selectedFile = null
this.previewUrl = ''
this.authorCopy = { this.authorCopy = {
...this.author ...this.author
} }
}, },
onFileChange(e) {
const file = e.target.files[0]
if (file) {
this.selectedFile = file
if (this.previewUrl) {
URL.revokeObjectURL(this.previewUrl)
}
this.previewUrl = URL.createObjectURL(file)
this.imageUrl = ''
}
},
onUrlInput() {
if (this.selectedFile) {
this.clearSelection()
}
},
clearSelection() {
this.imageUrl = ''
if (this.previewUrl) {
URL.revokeObjectURL(this.previewUrl)
this.previewUrl = ''
}
this.selectedFile = null
},
removeClick() { removeClick() {
const payload = { const payload = {
message: this.$getString('MessageConfirmRemoveAuthor', [this.author.name]), message: this.$getString('MessageConfirmRemoveAuthor', [this.author.name]),
@ -168,7 +200,6 @@ export default {
.$delete(`/api/authors/${this.authorId}/image`) .$delete(`/api/authors/${this.authorId}/image`)
.then((data) => { .then((data) => {
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess) this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
this.authorCopy.updatedAt = data.author.updatedAt this.authorCopy.updatedAt = data.author.updatedAt
this.authorCopy.imagePath = data.author.imagePath this.authorCopy.imagePath = data.author.imagePath
}) })
@ -181,6 +212,28 @@ export default {
}) })
}, },
submitUploadCover() { submitUploadCover() {
if (this.selectedFile) {
this.processing = true
const formData = new FormData()
formData.append('image', this.selectedFile)
this.$axios
.$post(`/api/authors/${this.authorId}/image`, formData)
.then((data) => {
this.clearSelection()
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
this.authorCopy.updatedAt = data.author.updatedAt
this.authorCopy.imagePath = data.author.imagePath
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error(error.response?.data || this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.processing = false
})
return
}
if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) { if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) {
this.$toast.error(this.$strings.ToastInvalidImageUrl) this.$toast.error(this.$strings.ToastInvalidImageUrl)
return return
@ -193,15 +246,14 @@ export default {
this.$axios this.$axios
.$post(`/api/authors/${this.authorId}/image`, updatePayload) .$post(`/api/authors/${this.authorId}/image`, updatePayload)
.then((data) => { .then((data) => {
this.imageUrl = '' this.clearSelection()
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess) this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
this.authorCopy.updatedAt = data.author.updatedAt this.authorCopy.updatedAt = data.author.updatedAt
this.authorCopy.imagePath = data.author.imagePath this.authorCopy.imagePath = data.author.imagePath
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
this.$toast.error(error.response.data || this.$strings.ToastRemoveFailed) this.$toast.error(error.response?.data || this.$strings.ToastRemoveFailed)
}) })
.finally(() => { .finally(() => {
this.processing = false this.processing = false
@ -245,7 +297,10 @@ export default {
this.processing = false this.processing = false
} }
}, },
mounted() {}, beforeDestroy() {
beforeDestroy() {} if (this.previewUrl) {
URL.revokeObjectURL(this.previewUrl)
}
}
} }
</script> </script>

View file

@ -2,6 +2,8 @@ const { Request, Response, NextFunction } = require('express')
const sequelize = require('sequelize') const sequelize = require('sequelize')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const { createNewSortInstance } = require('../libs/fastSort') const { createNewSortInstance } = require('../libs/fastSort')
const axios = require('axios')
const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
@ -258,7 +260,7 @@ class AuthorController {
/** /**
* POST: /api/authors/:id/image * POST: /api/authors/:id/image
* Upload author image from web URL * Upload author image from file (express-fileupload) or URL
* *
* @param {AuthorControllerRequest} req * @param {AuthorControllerRequest} req
* @param {Response} res * @param {Response} res
@ -268,30 +270,42 @@ class AuthorController {
Logger.warn(`User "${req.user.username}" attempted to upload an image without permission`) Logger.warn(`User "${req.user.username}" attempted to upload an image without permission`)
return res.sendStatus(403) return res.sendStatus(403)
} }
if (!req.body.url) {
Logger.error(`[AuthorController] Invalid request payload. 'url' not in request body`)
return res.status(400).send(`Invalid request payload. 'url' not in request body`)
}
if (!req.body.url.startsWith?.('http:') && !req.body.url.startsWith?.('https:')) {
Logger.error(`[AuthorController] Invalid request payload. Invalid url "${req.body.url}"`)
return res.status(400).send(`Invalid request payload. Invalid url "${req.body.url}"`)
}
Logger.debug(`[AuthorController] Requesting download author image from url "${req.body.url}"`) let imagePath = null
// 1. File upload (express-fileupload)
if (req.files && req.files.image) {
const uploadedFile = req.files.image
const uploadsDir = Path.join(global.MetadataPath, 'uploads')
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true })
}
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9)
const fileName = 'image-' + uniqueSuffix + Path.extname(uploadedFile.name)
const filePath = Path.join(uploadsDir, fileName)
await uploadedFile.mv(filePath)
imagePath = filePath
}
// 2. URL upload
else if (req.body.url && (req.body.url.startsWith('http://') || req.body.url.startsWith('https://'))) {
const result = await AuthorFinder.saveAuthorImage(req.author.id, req.body.url) const result = await AuthorFinder.saveAuthorImage(req.author.id, req.body.url)
if (result?.error) { if (result?.error) {
return res.status(400).send(result.error) return res.status(400).send(result.error)
} else if (!result?.path) { } else if (!result?.path) {
return res.status(500).send('Unknown error occurred') return res.status(500).send('Unknown error occurred')
} }
imagePath = result.path
if (req.author.imagePath) { } else {
await CacheManager.purgeImageCache(req.author.id) // Purge cache Logger.error(`[AuthorController] Invalid request payload. No file or valid url`)
return res.status(400).send(`Invalid request payload. No file or valid url`)
} }
req.author.imagePath = result.path if (req.author.imagePath) {
// imagePath may not have changed, but we still want to update the updatedAt field to bust image cache await CacheManager.purgeImageCache(req.author.id)
}
req.author.imagePath = imagePath
req.author.changed('imagePath', true) req.author.changed('imagePath', true)
await req.author.save() await req.author.save()
@ -311,20 +325,40 @@ class AuthorController {
*/ */
async deleteImage(req, res) { async deleteImage(req, res) {
if (!req.author.imagePath) { if (!req.author.imagePath) {
Logger.error(`[AuthorController] Author "${req.author.imagePath}" has no imagePath set`)
return res.status(400).send('Author has no image path set') return res.status(400).send('Author has no image path set')
} }
Logger.info(`[AuthorController] Removing image for author "${req.author.name}" at "${req.author.imagePath}"`) Logger.info(`[AuthorController] Removing image for author "${req.author.name}" at "${req.author.imagePath}"`)
await CacheManager.purgeImageCache(req.author.id) // Purge cache
// Clear cache
try {
await CacheManager.purgeImageCache(req.author.id)
} catch (e) {
console.log('Cache purge error (ignored):', e.message)
}
// Only local files are deleted
if (!req.author.imagePath.startsWith('http')) {
await CoverManager.removeFile(req.author.imagePath) await CoverManager.removeFile(req.author.imagePath)
}
req.author.imagePath = null req.author.imagePath = null
// Mark changes and explicitly update updatedAt to ensure the client receives the new timestamp
req.author.updatedAt = new Date()
req.author.changed('imagePath', true)
await req.author.save() await req.author.save()
// Clear cache again
try {
await CacheManager.purgeImageCache(req.author.id)
} catch (e) {
console.log('Cache purge error (ignored):', e.message)
}
const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id) const numBooks = await Database.bookAuthorModel.getCountForAuthor(req.author.id)
SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks)) SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks))
res.json({
author: req.author.toOldJSON() res.json({ author: req.author.toOldJSON() })
})
} }
/** /**
@ -388,37 +422,52 @@ class AuthorController {
* @param {Response} res * @param {Response} res
*/ */
async getImage(req, res) { async getImage(req, res) {
const { const { raw } = req.query
query: { width, height, format, raw } const author = await Database.authorModel.findByPk(req.params.id)
} = req if (!author || !author.imagePath) return res.sendStatus(404)
const authorId = req.params.id // Set no-cache headers to always get the latest image
res.set({
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
Expires: '0'
})
// Proxy external image URLs
if (author.imagePath.startsWith('http')) {
try {
const response = await axios.get(author.imagePath, { responseType: 'stream' })
res.setHeader('Content-Type', response.headers['content-type'] || 'image/jpeg')
response.data.pipe(res)
} catch (err) {
return res.sendStatus(404)
}
return
}
// Local file
let imagePath = author.imagePath
if (!Path.isAbsolute(imagePath)) {
imagePath = Path.resolve(process.cwd(), imagePath)
}
if (!(await fs.pathExists(imagePath))) {
return res.sendStatus(404)
}
if (raw) { if (raw) {
const author = await Database.authorModel.findByPk(authorId) return res.sendFile(imagePath)
if (!author) {
Logger.warn(`[AuthorController] Author "${authorId}" not found`)
return res.sendStatus(404)
}
if (!author.imagePath || !(await fs.pathExists(author.imagePath))) {
Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`)
return res.sendStatus(404)
}
return res.sendFile(author.imagePath)
} }
const options = { const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'), format: 'jpeg',
height: height ? parseInt(height) : null, width: req.query.width ? parseInt(req.query.width) : null,
width: width ? parseInt(width) : null height: req.query.height ? parseInt(req.query.height) : null
} }
return CacheManager.handleAuthorCache(res, authorId, options) return CacheManager.handleAuthorCache(res, author.id, options)
} }
/** /**
* * Middleware for author routes
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
* @param {NextFunction} next * @param {NextFunction} next
@ -426,6 +475,7 @@ class AuthorController {
async middleware(req, res, next) { async middleware(req, res, next) {
const author = await Database.authorModel.findByPk(req.params.id) const author = await Database.authorModel.findByPk(req.params.id)
if (!author) return res.sendStatus(404) if (!author) return res.sendStatus(404)
req.author = author
if (req.method == 'DELETE' && !req.user.canDelete) { if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[AuthorController] User "${req.user.username}" attempted to delete without permission`) Logger.warn(`[AuthorController] User "${req.user.username}" attempted to delete without permission`)
@ -435,7 +485,6 @@ class AuthorController {
return res.sendStatus(403) return res.sendStatus(403)
} }
req.author = author
next() next()
} }
} }