added comments to books

This commit is contained in:
zipben 2025-06-04 13:00:41 +00:00
parent acc3d253c5
commit 396ecfff4a
11 changed files with 583 additions and 28 deletions

View File

@ -139,6 +139,51 @@
<tables-library-files-table v-if="libraryFiles.length" :library-item="libraryItem" class="mt-6" />
</div>
</div>
<!-- Comments section -->
<div class="max-w-6xl mx-auto">
<div class="flex flex-col lg:flex-row">
<div class="w-full lg:w-52" style="min-width: 208px">
<!-- Spacer div to match the layout above -->
</div>
<div class="grow px-2 md:px-10">
<div class="mt-12">
<h3 class="text-xl font-semibold mb-4">{{ $strings.LabelComments }}</h3>
<!-- Add comment form -->
<div class="mb-6">
<div class="bg-bg rounded-lg border border-gray-600 p-4">
<textarea v-model="newCommentText" placeholder="Add a comment... (Ctrl+Enter to post)" class="w-full bg-bg text-white resize-y min-h-[80px] focus:outline-none" @keydown.ctrl.enter="logNewComment"></textarea>
<div class="flex justify-end mt-2">
<button class="px-4 py-2 bg-success text-white rounded-md hover:bg-success/80" @click="logNewComment">
{{ $strings.ButtonPost }}
</button>
</div>
</div>
</div>
<!-- Comments list -->
<div v-if="libraryItem.comments && libraryItem.comments.length" class="space-y-4">
<div v-for="comment in libraryItem.comments" :key="comment.id" class="bg-bg rounded-lg p-4 border border-gray-600">
<div class="flex justify-between items-start mb-2">
<div>
<span class="font-medium">{{ comment.user.username }}</span>
<span class="text-gray-400 text-sm ml-2">{{ formatDate(comment.createdAt) }}</span>
</div>
<button v-if="canDeleteComment(comment)" @click="deleteComment(comment.id)" class="text-red-500 hover:text-red-400">
{{ $strings.ButtonDelete }}
</button>
</div>
<p class="text-gray-200 whitespace-pre-wrap">{{ comment.text }}</p>
</div>
</div>
<div v-else class="text-gray-400 text-center py-4">
{{ $strings.MessageNoComments }}
</div>
</div>
</div>
</div>
</div>
</div>
<modals-podcast-episode-feed v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" :download-queue="episodeDownloadsQueued" :episodes-downloading="episodesDownloading" />
@ -148,6 +193,9 @@
<script>
export default {
components: {
// Remove unused comments component registration
},
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
@ -182,7 +230,11 @@ export default {
episodeDownloadsQueued: [],
showBookmarksModal: false,
isDescriptionClamped: false,
showFullDescription: false
showFullDescription: false,
newCommentText: '',
editingComment: null,
isAddingComment: false,
isUpdatingComment: false
}
},
computed: {
@ -431,6 +483,9 @@ export default {
}
return items
},
currentUser() {
return this.$store.state.user.user
}
},
methods: {
@ -777,6 +832,65 @@ export default {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShareModal', this.mediaItemShare)
}
},
async logNewComment() {
if (!this.newCommentText.trim()) return
try {
// Save the comment first
const savedComment = await this.$axios.$post(`/api/items/${this.libraryItemId}/comments`, {
text: this.newCommentText
})
// Initialize comments array if it doesn't exist
if (!this.libraryItem.comments) {
this.libraryItem.comments = []
}
// Add the saved comment to the beginning of the array
this.libraryItem.comments.unshift(savedComment)
// Clear the input after successful save
this.newCommentText = ''
this.$toast.success(this.$strings.MessageCommentAdded)
} catch (error) {
console.error('Error saving comment:', error)
this.$toast.error(this.$strings.ErrorAddingComment)
}
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
},
canDeleteComment(comment) {
return this.currentUser.id === comment.userId || this.currentUser.isAdmin
},
async deleteComment(commentId) {
if (!confirm(this.$strings.ConfirmDeleteComment)) return
try {
// Remove comment from the array
const index = this.libraryItem.comments.findIndex((c) => c.id === commentId)
if (index !== -1) {
this.libraryItem.comments.splice(index, 1)
// Delete the comment
await this.$axios.$delete(`/api/items/${this.libraryItemId}/comments/${commentId}`)
this.$toast.success(this.$strings.MessageCommentDeleted)
}
} catch (error) {
console.error('Error deleting comment:', error)
this.$toast.error(this.$strings.ErrorDeletingComment)
// Restore the comment if the delete failed
if (this.editingComment) {
this.libraryItem.comments.splice(index, 0, this.editingComment)
}
}
}
},
mounted() {

View File

@ -1103,5 +1103,17 @@
"ToastUserPasswordChangeSuccess": "Password changed successfully",
"ToastUserPasswordMismatch": "Passwords do not match",
"ToastUserPasswordMustChange": "New password cannot match old password",
"ToastUserRootRequireName": "Must enter a root username"
"ToastUserRootRequireName": "Must enter a root username",
"LabelComments": "Comments",
"PlaceholderAddComment": "Add a comment... (Ctrl+Enter to post)",
"ButtonPost": "Post",
"MessageNoComments": "No comments yet. Be the first to comment!",
"MessageCommentAdded": "Comment added successfully",
"MessageCommentUpdated": "Comment updated successfully",
"MessageCommentDeleted": "Comment deleted successfully",
"ErrorLoadingComments": "Error loading comments",
"ErrorAddingComment": "Error adding comment",
"ErrorUpdatingComment": "Error updating comment",
"ErrorDeletingComment": "Error deleting comment",
"ConfirmDeleteComment": "Are you sure you want to delete this comment?"
}

18
client/strings/en.json Normal file
View File

@ -0,0 +1,18 @@
{
"LabelComments": "Comments",
"PlaceholderAddComment": "Add a comment... (Ctrl+Enter to post)",
"ButtonPost": "Post",
"ButtonEdit": "Edit",
"ButtonDelete": "Delete",
"ButtonCancel": "Cancel",
"ButtonSave": "Save",
"MessageNoComments": "No comments yet. Be the first to comment!",
"MessageCommentAdded": "Comment added successfully",
"MessageCommentUpdated": "Comment updated successfully",
"MessageCommentDeleted": "Comment deleted successfully",
"ErrorLoadingComments": "Error loading comments",
"ErrorAddingComment": "Error adding comment",
"ErrorUpdatingComment": "Error updating comment",
"ErrorDeletingComment": "Error deleting comment",
"ConfirmDeleteComment": "Are you sure you want to delete this comment?"
}

View File

@ -152,6 +152,11 @@ class Database {
return this.models.device
}
/** @type {typeof import('./models/Comment')} */
get commentModel() {
return this.models.comment
}
/**
* Check if db file exists
* @returns {boolean}
@ -333,6 +338,14 @@ class Database {
require('./models/Setting').init(this.sequelize)
require('./models/CustomMetadataProvider').init(this.sequelize)
require('./models/MediaItemShare').init(this.sequelize)
require('./models/Comment').init(this.sequelize)
// Set up associations after all models are initialized
Object.values(this.sequelize.models).forEach(model => {
if (typeof model.associate === 'function') {
model.associate(this.sequelize.models)
}
})
return this.sequelize.sync({ force, alter: false })
}

View File

@ -76,6 +76,18 @@ class LibraryItemController {
}
}
// Include comments
const comments = await Database.commentModel.findAll({
where: { libraryItemId: req.params.id },
include: [{
model: Database.userModel,
as: 'user',
attributes: ['id', 'username']
}],
order: [['createdAt', 'DESC']]
})
item.comments = comments
return res.json(item)
}
res.json(req.libraryItem.toOldJSON())
@ -1193,5 +1205,121 @@ class LibraryItemController {
next()
}
/**
* GET: /api/items/:id/comments
* Get comments for a library item
*
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async getComments(req, res) {
try {
const comments = await Database.commentModel.findAll({
where: { libraryItemId: req.params.id },
include: [{
model: Database.userModel,
as: 'user',
attributes: ['id', 'username']
}],
order: [['createdAt', 'DESC']]
})
res.json(comments)
} catch (error) {
Logger.error('[LibraryItemController] getComments error:', error)
res.status(500).json({ error: 'Failed to get comments' })
}
}
/**
* POST: /api/items/:id/comments
* Add a comment to a library item
*
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async addComment(req, res) {
try {
const comment = await Database.commentModel.create({
text: req.body.text,
libraryItemId: req.params.id,
userId: req.user.id
})
const commentWithUser = await Database.commentModel.findByPk(comment.id, {
include: [{
model: Database.userModel,
as: 'user',
attributes: ['id', 'username']
}]
})
res.json(commentWithUser)
} catch (error) {
Logger.error('[LibraryItemController] addComment error:', error)
res.status(500).json({ error: 'Failed to add comment' })
}
}
/**
* PATCH: /api/items/:id/comments/:commentId
* Update a comment
*
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async updateComment(req, res) {
try {
const comment = await Database.commentModel.findByPk(req.params.commentId, {
include: [{
model: Database.userModel,
as: 'user',
attributes: ['id', 'username']
}]
})
if (!comment) {
return res.status(404).json({ error: 'Comment not found' })
}
// Only allow comment owner or admin to update
if (comment.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Not authorized to update this comment' })
}
await comment.update({ text: req.body.text })
res.json(comment)
} catch (error) {
Logger.error('[LibraryItemController] updateComment error:', error)
res.status(500).json({ error: 'Failed to update comment' })
}
}
/**
* DELETE: /api/items/:id/comments/:commentId
* Delete a comment
*
* @param {LibraryItemControllerRequest} req
* @param {Response} res
*/
async deleteComment(req, res) {
try {
const comment = await Database.commentModel.findByPk(req.params.commentId)
if (!comment) {
return res.status(404).json({ error: 'Comment not found' })
}
// Only allow comment owner or admin to delete
if (comment.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).json({ error: 'Not authorized to delete this comment' })
}
await comment.destroy()
res.json({ success: true })
} catch (error) {
Logger.error('[LibraryItemController] deleteComment error:', error)
res.status(500).json({ error: 'Failed to delete comment' })
}
}
}
module.exports = new LibraryItemController()

View File

@ -0,0 +1,61 @@
const { DataTypes } = require('sequelize')
async function up({ context: { queryInterface, logger } }) {
logger.info('[2.20.1 migration] Creating comments table')
await queryInterface.createTable('comments', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
text: {
type: DataTypes.TEXT,
allowNull: false
},
libraryItemId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'libraryItems',
key: 'id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
}
})
// Add indexes for faster lookups
await queryInterface.addIndex('comments', ['libraryItemId'])
await queryInterface.addIndex('comments', ['userId'])
logger.info('[2.20.1 migration] Comments table created successfully')
}
async function down({ context: { queryInterface, logger } }) {
logger.info('[2.20.1 migration] Dropping comments table')
await queryInterface.dropTable('comments')
logger.info('[2.20.1 migration] Comments table dropped successfully')
}
module.exports = { up, down }

83
server/models/Comment.js Normal file
View File

@ -0,0 +1,83 @@
const { Model, DataTypes } = require('sequelize')
class Comment extends Model {
static init(sequelize) {
return super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
text: {
type: DataTypes.TEXT,
allowNull: false
},
libraryItemId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'libraryItems',
key: 'id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
}
},
{
sequelize,
modelName: 'comment',
tableName: 'comments',
timestamps: true
}
)
}
static associate(models) {
Comment.belongsTo(models.user, {
foreignKey: 'userId',
as: 'user'
})
Comment.belongsTo(models.libraryItem, {
foreignKey: 'libraryItemId',
as: 'libraryItem'
})
}
toJSON() {
return {
id: this.id,
text: this.text,
userId: this.userId,
libraryItemId: this.libraryItemId,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
user: this.user ? {
id: this.user.id,
username: this.user.username
} : null
}
}
}
module.exports = Comment

View File

@ -191,7 +191,20 @@ class LibraryItem extends Model {
static async getExpandedById(libraryItemId) {
if (!libraryItemId) return null
const libraryItem = await this.findByPk(libraryItemId)
const libraryItem = await this.findByPk(libraryItemId, {
include: [
{
model: this.sequelize.models.comment,
as: 'comments',
include: [{
model: this.sequelize.models.user,
as: 'user',
attributes: ['id', 'username']
}],
order: [['createdAt', 'DESC']]
}
]
})
if (!libraryItem) {
Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`)
return null
@ -734,31 +747,6 @@ class LibraryItem extends Model {
}
)
const { library, libraryFolder, book, podcast } = sequelize.models
library.hasMany(LibraryItem)
LibraryItem.belongsTo(library)
libraryFolder.hasMany(LibraryItem)
LibraryItem.belongsTo(libraryFolder)
book.hasOne(LibraryItem, {
foreignKey: 'mediaId',
constraints: false,
scope: {
mediaType: 'book'
}
})
LibraryItem.belongsTo(book, { foreignKey: 'mediaId', constraints: false })
podcast.hasOne(LibraryItem, {
foreignKey: 'mediaId',
constraints: false,
scope: {
mediaType: 'podcast'
}
})
LibraryItem.belongsTo(podcast, { foreignKey: 'mediaId', constraints: false })
LibraryItem.addHook('afterFind', (findResult) => {
if (!findResult) return
@ -786,6 +774,40 @@ class LibraryItem extends Model {
media.destroy()
}
})
return this
}
static associate(models) {
const { library, libraryFolder, book, podcast, comment } = models
library.hasMany(LibraryItem)
LibraryItem.belongsTo(library)
libraryFolder.hasMany(LibraryItem)
LibraryItem.belongsTo(libraryFolder)
book.hasOne(LibraryItem, {
foreignKey: 'mediaId',
constraints: false,
scope: {
mediaType: 'book'
}
})
LibraryItem.belongsTo(book, { foreignKey: 'mediaId', constraints: false })
podcast.hasOne(LibraryItem, {
foreignKey: 'mediaId',
constraints: false,
scope: {
mediaType: 'podcast'
}
})
LibraryItem.belongsTo(podcast, { foreignKey: 'mediaId', constraints: false })
LibraryItem.hasMany(comment, {
foreignKey: 'libraryItemId',
as: 'comments'
})
}
get isBook() {

View File

@ -126,6 +126,12 @@ class ApiRouter {
this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this))
this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this))
// Comment routes
this.router.get('/items/:id/comments', LibraryItemController.middleware.bind(this), LibraryItemController.getComments.bind(this))
this.router.post('/items/:id/comments', LibraryItemController.middleware.bind(this), LibraryItemController.addComment.bind(this))
this.router.patch('/items/:id/comments/:commentId', LibraryItemController.middleware.bind(this), LibraryItemController.updateComment.bind(this))
this.router.delete('/items/:id/comments/:commentId', LibraryItemController.middleware.bind(this), LibraryItemController.deleteComment.bind(this))
//
// User Routes
//

View File

@ -0,0 +1,98 @@
const express = require('express')
const router = express.Router()
// Get library item by id
router.get('/:id', async (req, res) => {
const libraryItem = await req.ctx.models.libraryItem.getExpandedById(req.params.id)
if (!libraryItem) {
return res.status(404).send({ error: 'Library item not found' })
}
res.json(libraryItem.toOldJSONExpanded())
})
// Get comments for a library item
router.get('/:id/comments', async (req, res) => {
const libraryItem = await req.ctx.models.libraryItem.findByPk(req.params.id)
if (!libraryItem) {
return res.status(404).send({ error: 'Library item not found' })
}
const comments = await req.ctx.models.comment.findAll({
where: { libraryItemId: req.params.id },
include: [{
model: req.ctx.models.user,
as: 'user',
attributes: ['id', 'username']
}],
order: [['createdAt', 'DESC']]
})
res.json(comments)
})
// Add a comment to a library item
router.post('/:id/comments', async (req, res) => {
const libraryItem = await req.ctx.models.libraryItem.findByPk(req.params.id)
if (!libraryItem) {
return res.status(404).send({ error: 'Library item not found' })
}
const comment = await req.ctx.models.comment.create({
text: req.body.text,
libraryItemId: req.params.id,
userId: req.user.id
})
const commentWithUser = await req.ctx.models.comment.findByPk(comment.id, {
include: [{
model: req.ctx.models.user,
as: 'user',
attributes: ['id', 'username']
}]
})
res.json(commentWithUser)
})
// Update a comment
router.patch('/:itemId/comments/:commentId', async (req, res) => {
const comment = await req.ctx.models.comment.findByPk(req.params.commentId)
if (!comment) {
return res.status(404).send({ error: 'Comment not found' })
}
if (comment.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).send({ error: 'Not authorized to update this comment' })
}
await comment.update({
text: req.body.text
})
const updatedComment = await req.ctx.models.comment.findByPk(comment.id, {
include: [{
model: req.ctx.models.user,
as: 'user',
attributes: ['id', 'username']
}]
})
res.json(updatedComment)
})
// Delete a comment
router.delete('/:itemId/comments/:commentId', async (req, res) => {
const comment = await req.ctx.models.comment.findByPk(req.params.commentId)
if (!comment) {
return res.status(404).send({ error: 'Comment not found' })
}
if (comment.userId !== req.user.id && !req.user.isAdmin) {
return res.status(403).send({ error: 'Not authorized to delete this comment' })
}
await comment.destroy()
res.json({ success: true })
})
module.exports = router