Fix merge conflicts

This commit is contained in:
江山 2025-10-17 10:53:16 +00:00
parent f37d6fa3cd
commit 2f93d96b12
3 changed files with 202 additions and 96 deletions

View file

@ -1,6 +1,7 @@
<template>
<div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden">
<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">
<div ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden flex items-center justify-center">
<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 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
@ -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" />
</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>
</template>
@ -25,55 +22,68 @@ export default {
props: {
author: {
type: Object,
default: () => {}
default: () => ({})
},
rounded: {
type: String,
default: 'lg'
},
uploadedFile: {
type: [File, null],
default: null
},
uploadedUrl: {
type: String,
default: ''
},
altText: {
type: String,
default: 'author image'
}
},
data() {
return {
showCoverBg: false,
coverContain: true
objectUrl: null
}
},
computed: {
_author() {
return this.author || {}
},
authorId() {
return this._author.id
},
imagePath() {
return this._author.imagePath
return this.author && this.author.imagePath
},
updatedAt() {
return this._author.updatedAt
return this.author && this.author.updatedAt
},
imgSrc() {
if (!this.imagePath) return null
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
}
},
methods: {
<<<<<<< Updated upstream
imageLoaded() {
if (this.$refs.img) {
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
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)
}
}
}
=======
imageLoaded() {}
>>>>>>> Stashed changes
},
mounted() {}
methods: {
imageLoaded() {}
},
beforeDestroy() {
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl)
this.objectUrl = null
}
}
}
</script>

View file

@ -9,27 +9,21 @@
<div class="flex">
<div class="w-40 p-2">
<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">
<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 class="grow">
<<<<<<< Updated upstream
<form @submit.prevent="submitUploadCover" class="flex grow mb-2 p-2">
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
<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>
=======
<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" @input="onUrlInput" />
<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" />
{{ $strings.ButtonUpload }}
{{ $strings.ButtonUpload || 'Upload' }}
</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>
>>>>>>> Stashed changes
<ui-btn v-if="selectedFile || previewUrl" type="button" color="bg-error" class="ml-2" @click="clearSelection">Cancel</ui-btn>
</form>
<form v-if="author" @submit.prevent="submitForm">
@ -49,7 +43,6 @@
<ui-btn v-if="userCanDelete" small color="bg-error" type="button" @click.stop="removeClick">{{ $strings.ButtonRemove }}</ui-btn>
<div class="grow" />
<ui-btn type="button" class="mx-2" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</form>
@ -69,6 +62,8 @@ export default {
description: ''
},
imageUrl: '',
selectedFile: null,
previewUrl: '',
processing: false
}
},
@ -114,10 +109,36 @@ export default {
methods: {
init() {
this.imageUrl = ''
this.selectedFile = null
this.previewUrl = ''
this.authorCopy = {
...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() {
const payload = {
message: this.$getString('MessageConfirmRemoveAuthor', [this.author.name]),
@ -179,7 +200,6 @@ export default {
.$delete(`/api/authors/${this.authorId}/image`)
.then((data) => {
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
this.authorCopy.updatedAt = data.author.updatedAt
this.authorCopy.imagePath = data.author.imagePath
})
@ -192,6 +212,28 @@ export default {
})
},
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:')) {
this.$toast.error(this.$strings.ToastInvalidImageUrl)
return
@ -204,15 +246,14 @@ export default {
this.$axios
.$post(`/api/authors/${this.authorId}/image`, updatePayload)
.then((data) => {
this.imageUrl = ''
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)
this.$toast.error(error.response?.data || this.$strings.ToastRemoveFailed)
})
.finally(() => {
this.processing = false
@ -256,7 +297,10 @@ export default {
this.processing = false
}
},
mounted() {},
beforeDestroy() {}
beforeDestroy() {
if (this.previewUrl) {
URL.revokeObjectURL(this.previewUrl)
}
}
}
</script>

View file

@ -2,6 +2,8 @@ const { Request, Response, NextFunction } = require('express')
const sequelize = require('sequelize')
const fs = require('../libs/fsExtra')
const { createNewSortInstance } = require('../libs/fastSort')
const axios = require('axios')
const Path = require('path')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
@ -258,7 +260,7 @@ class AuthorController {
/**
* POST: /api/authors/:id/image
* Upload author image from web URL
* Upload author image from file (express-fileupload) or URL
*
* @param {AuthorControllerRequest} req
* @param {Response} res
@ -268,30 +270,45 @@ class AuthorController {
Logger.warn(`User "${req.user.username}" attempted to upload an image without permission`)
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}"`)
const result = await AuthorFinder.saveAuthorImage(req.author.id, req.body.url)
let imagePath = null
if (result?.error) {
return res.status(400).send(result.error)
} else if (!result?.path) {
return res.status(500).send('Unknown error occurred')
if (req.files && req.files.image) {
const uploadedFile = req.files.image
const uploadsDir = Path.join(global.MetadataPath, 'authors')
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true })
}
const fileName = `${req.author.id}${Path.extname(uploadedFile.name)}`
const filePath = Path.join(uploadsDir, fileName)
// Remove existing file if it exists
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
await uploadedFile.mv(filePath)
imagePath = filePath
} 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)
if (result?.error) {
return res.status(400).send(result.error)
} else if (!result?.path) {
return res.status(500).send('Unknown error occurred')
}
imagePath = result.path
} else {
Logger.error(`[AuthorController] Invalid request payload. No file or valid url`)
return res.status(400).send(`Invalid request payload. No file or valid url`)
}
if (req.author.imagePath) {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
await CacheManager.purgeImageCache(req.author.id)
}
req.author.imagePath = result.path
// imagePath may not have changed, but we still want to update the updatedAt field to bust image cache
req.author.imagePath = imagePath
req.author.changed('imagePath', true)
await req.author.save()
@ -311,20 +328,40 @@ class AuthorController {
*/
async deleteImage(req, res) {
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')
}
Logger.info(`[AuthorController] Removing image for author "${req.author.name}" at "${req.author.imagePath}"`)
await CacheManager.purgeImageCache(req.author.id) // Purge cache
await CoverManager.removeFile(req.author.imagePath)
// 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)
}
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()
// 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)
SocketAuthority.emitter('author_updated', req.author.toOldJSONExpanded(numBooks))
res.json({
author: req.author.toOldJSON()
})
res.json({ author: req.author.toOldJSON() })
}
/**
@ -388,37 +425,52 @@ class AuthorController {
* @param {Response} res
*/
async getImage(req, res) {
const {
query: { width, height, format, raw }
} = req
const { raw } = req.query
const author = await Database.authorModel.findByPk(req.params.id)
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) {
const author = await Database.authorModel.findByPk(authorId)
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)
return res.sendFile(imagePath)
}
const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null
format: 'jpeg',
width: req.query.width ? parseInt(req.query.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 {Response} res
* @param {NextFunction} next
@ -426,6 +478,7 @@ class AuthorController {
async middleware(req, res, next) {
const author = await Database.authorModel.findByPk(req.params.id)
if (!author) return res.sendStatus(404)
req.author = author
if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[AuthorController] User "${req.user.username}" attempted to delete without permission`)
@ -435,7 +488,6 @@ class AuthorController {
return res.sendStatus(403)
}
req.author = author
next()
}
}