فهرست منبع

feat: reactions

Timothy Jaeryang Baek 4 ماه پیش
والد
کامیت
f93c2e4a8d

+ 70 - 0
backend/open_webui/migrations/versions/3781e22d8b01_update_message_table.py

@@ -0,0 +1,70 @@
+"""Update message & channel tables
+
+Revision ID: 3781e22d8b01
+Revises: 7826ab40b532
+Create Date: 2024-12-30 03:00:00.000000
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "3781e22d8b01"
+down_revision = "7826ab40b532"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # Add 'type' column to the 'channel' table
+    op.add_column(
+        "channel",
+        sa.Column(
+            "type",
+            sa.Text(),
+            nullable=True,
+        ),
+    )
+
+    # Add 'parent_id' column to the 'message' table for threads
+    op.add_column(
+        "message",
+        sa.Column("parent_id", sa.Text(), nullable=True),
+    )
+
+    op.create_table(
+        "message_reaction",
+        sa.Column(
+            "id", sa.Text(), nullable=False, primary_key=True, unique=True
+        ),  # Unique reaction ID
+        sa.Column("user_id", sa.Text(), nullable=False),  # User who reacted
+        sa.Column(
+            "message_id", sa.Text(), nullable=False
+        ),  # Message that was reacted to
+        sa.Column(
+            "name", sa.Text(), nullable=False
+        ),  # Reaction name (e.g. "thumbs_up")
+        sa.Column(
+            "created_at", sa.BigInteger(), nullable=True
+        ),  # Timestamp of when the reaction was added
+    )
+
+    op.create_table(
+        "channel_member",
+        sa.Column(
+            "id", sa.Text(), nullable=False, primary_key=True, unique=True
+        ),  # Record ID for the membership row
+        sa.Column("channel_id", sa.Text(), nullable=False),  # Associated channel
+        sa.Column("user_id", sa.Text(), nullable=False),  # Associated user
+        sa.Column(
+            "created_at", sa.BigInteger(), nullable=True
+        ),  # Timestamp of when the user joined the channel
+    )
+
+
+def downgrade():
+    # Revert 'type' column addition to the 'channel' table
+    op.drop_column("channel", "type")
+    op.drop_column("message", "parent_id")
+    op.drop_table("message_reaction")
+    op.drop_table("channel_member")

+ 6 - 2
backend/open_webui/models/channels.py

@@ -21,6 +21,7 @@ class Channel(Base):
 
     id = Column(Text, primary_key=True)
     user_id = Column(Text)
+    type = Column(Text, nullable=True)
 
     name = Column(Text)
     description = Column(Text, nullable=True)
@@ -38,9 +39,11 @@ class ChannelModel(BaseModel):
 
     id: str
     user_id: str
-    description: Optional[str] = None
+    type: Optional[str] = None
 
     name: str
+    description: Optional[str] = None
+
     data: Optional[dict] = None
     meta: Optional[dict] = None
     access_control: Optional[dict] = None
@@ -64,12 +67,13 @@ class ChannelForm(BaseModel):
 
 class ChannelTable:
     def insert_new_channel(
-        self, form_data: ChannelForm, user_id: str
+        self, type: Optional[str], form_data: ChannelForm, user_id: str
     ) -> Optional[ChannelModel]:
         with get_db() as db:
             channel = ChannelModel(
                 **{
                     **form_data.model_dump(),
+                    "type": type,
                     "name": form_data.name.lower(),
                     "id": str(uuid.uuid4()),
                     "user_id": user_id,

+ 95 - 16
backend/open_webui/models/messages.py

@@ -17,6 +17,25 @@ from sqlalchemy.sql import exists
 ####################
 
 
+class MessageReaction(Base):
+    __tablename__ = "message_reaction"
+    id = Column(Text, primary_key=True)
+    user_id = Column(Text)
+    message_id = Column(Text)
+    name = Column(Text)
+    created_at = Column(BigInteger)
+
+
+class MessageReactionModel(BaseModel):
+    model_config = ConfigDict(from_attributes=True)
+
+    id: str
+    user_id: str
+    message_id: str
+    name: str
+    created_at: int  # timestamp in epoch
+
+
 class Message(Base):
     __tablename__ = "message"
     id = Column(Text, primary_key=True)
@@ -24,6 +43,8 @@ class Message(Base):
     user_id = Column(Text)
     channel_id = Column(Text, nullable=True)
 
+    parent_id = Column(Text, nullable=True)
+
     content = Column(Text)
     data = Column(JSON, nullable=True)
     meta = Column(JSON, nullable=True)
@@ -39,6 +60,8 @@ class MessageModel(BaseModel):
     user_id: str
     channel_id: Optional[str] = None
 
+    parent_id: Optional[str] = None
+
     content: str
     data: Optional[dict] = None
     meta: Optional[dict] = None
@@ -54,10 +77,21 @@ class MessageModel(BaseModel):
 
 class MessageForm(BaseModel):
     content: str
+    parent_id: Optional[str] = None
     data: Optional[dict] = None
     meta: Optional[dict] = None
 
 
+class Reactions(BaseModel):
+    name: str
+    user_ids: list[str]
+    count: int
+
+
+class MessageResponse(MessageModel):
+    reactions: list[Reactions]
+
+
 class MessageTable:
     def insert_new_message(
         self, form_data: MessageForm, channel_id: str, user_id: str
@@ -71,6 +105,7 @@ class MessageTable:
                     "id": id,
                     "user_id": user_id,
                     "channel_id": channel_id,
+                    "parent_id": form_data.parent_id,
                     "content": form_data.content,
                     "data": form_data.data,
                     "meta": form_data.meta,
@@ -85,10 +120,19 @@ class MessageTable:
             db.refresh(result)
             return MessageModel.model_validate(result) if result else None
 
-    def get_message_by_id(self, id: str) -> Optional[MessageModel]:
+    def get_message_by_id(self, id: str) -> Optional[MessageResponse]:
         with get_db() as db:
             message = db.get(Message, id)
-            return MessageModel.model_validate(message) if message else None
+            if not message:
+                return None
+
+            reactions = self.get_reactions_by_message_id(id)
+            return MessageResponse(
+                **{
+                    **MessageModel.model_validate(message).model_dump(),
+                    "reactions": reactions,
+                }
+            )
 
     def get_messages_by_channel_id(
         self, channel_id: str, skip: int = 0, limit: int = 50
@@ -104,20 +148,6 @@ class MessageTable:
             )
             return [MessageModel.model_validate(message) for message in all_messages]
 
-    def get_messages_by_user_id(
-        self, user_id: str, skip: int = 0, limit: int = 50
-    ) -> list[MessageModel]:
-        with get_db() as db:
-            all_messages = (
-                db.query(Message)
-                .filter_by(user_id=user_id)
-                .order_by(Message.created_at.desc())
-                .offset(skip)
-                .limit(limit)
-                .all()
-            )
-            return [MessageModel.model_validate(message) for message in all_messages]
-
     def update_message_by_id(
         self, id: str, form_data: MessageForm
     ) -> Optional[MessageModel]:
@@ -131,9 +161,58 @@ class MessageTable:
             db.refresh(message)
             return MessageModel.model_validate(message) if message else None
 
+    def add_reaction_to_message(
+        self, id: str, user_id: str, name: str
+    ) -> Optional[MessageReactionModel]:
+        with get_db() as db:
+            reaction_id = str(uuid.uuid4())
+            reaction = MessageReactionModel(
+                id=reaction_id,
+                user_id=user_id,
+                message_id=id,
+                name=name,
+                created_at=int(time.time_ns()),
+            )
+            result = MessageReaction(**reaction.model_dump())
+            db.add(result)
+            db.commit()
+            db.refresh(result)
+            return MessageReactionModel.model_validate(result) if result else None
+
+    def get_reactions_by_message_id(self, id: str) -> list[Reactions]:
+        with get_db() as db:
+            all_reactions = db.query(MessageReaction).filter_by(message_id=id).all()
+
+            reactions = {}
+            for reaction in all_reactions:
+                if reaction.name not in reactions:
+                    reactions[reaction.name] = {
+                        "name": reaction.name,
+                        "user_ids": [],
+                        "count": 0,
+                    }
+                reactions[reaction.name]["user_ids"].append(reaction.user_id)
+                reactions[reaction.name]["count"] += 1
+
+            return [Reactions(**reaction) for reaction in reactions.values()]
+
+    def remove_reaction_by_id_and_user_id_and_name(
+        self, id: str, user_id: str, name: str
+    ) -> bool:
+        with get_db() as db:
+            db.query(MessageReaction).filter_by(
+                message_id=id, user_id=user_id, name=name
+            ).delete()
+            db.commit()
+            return True
+
     def delete_message_by_id(self, id: str) -> bool:
         with get_db() as db:
             db.query(Message).filter_by(id=id).delete()
+
+            # Delete all reactions to this message
+            db.query(MessageReaction).filter_by(message_id=id).delete()
+
             db.commit()
             return True
 

+ 145 - 5
backend/open_webui/routers/channels.py

@@ -11,7 +11,12 @@ from open_webui.socket.main import sio, get_user_ids_from_room
 from open_webui.models.users import Users, UserNameResponse
 
 from open_webui.models.channels import Channels, ChannelModel, ChannelForm
-from open_webui.models.messages import Messages, MessageModel, MessageForm
+from open_webui.models.messages import (
+    Messages,
+    MessageModel,
+    MessageResponse,
+    MessageForm,
+)
 
 
 from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
@@ -49,7 +54,7 @@ async def get_channels(user=Depends(get_verified_user)):
 @router.post("/create", response_model=Optional[ChannelModel])
 async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user)):
     try:
-        channel = Channels.insert_new_channel(form_data, user.id)
+        channel = Channels.insert_new_channel(None, form_data, user.id)
         return ChannelModel(**channel.model_dump())
     except Exception as e:
         log.exception(e)
@@ -134,11 +139,11 @@ async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
 ############################
 
 
-class MessageUserModel(MessageModel):
+class MessageUserResponse(MessageResponse):
     user: UserNameResponse
 
 
-@router.get("/{id}/messages", response_model=list[MessageUserModel])
+@router.get("/{id}/messages", response_model=list[MessageUserResponse])
 async def get_channel_messages(
     id: str, skip: int = 0, limit: int = 50, user=Depends(get_verified_user)
 ):
@@ -165,9 +170,10 @@ async def get_channel_messages(
             users[message.user_id] = user
 
         messages.append(
-            MessageUserModel(
+            MessageUserResponse(
                 **{
                     **message.model_dump(),
+                    "reactions": Messages.get_reactions_by_message_id(message.id),
                     "user": UserNameResponse(**users[message.user_id].model_dump()),
                 }
             )
@@ -333,6 +339,140 @@ async def update_message_by_id(
         )
 
 
+############################
+# AddReactionToMessage
+############################
+
+
+class ReactionForm(BaseModel):
+    name: str
+
+
+@router.post("/{id}/messages/{message_id}/reactions/add", response_model=bool)
+async def add_reaction_to_message(
+    id: str, message_id: str, form_data: ReactionForm, user=Depends(get_verified_user)
+):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="read", access_control=channel.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    message = Messages.get_message_by_id(message_id)
+    if not message:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if message.channel_id != id:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    try:
+        Messages.add_reaction_to_message(message_id, user.id, form_data.name)
+
+        message = Messages.get_message_by_id(message_id)
+        await sio.emit(
+            "channel-events",
+            {
+                "channel_id": channel.id,
+                "message_id": message.id,
+                "data": {
+                    "type": "message:reaction",
+                    "data": {
+                        **message.model_dump(),
+                        "user": UserNameResponse(**user.model_dump()).model_dump(),
+                        "name": form_data.name,
+                    },
+                },
+                "user": UserNameResponse(**user.model_dump()).model_dump(),
+                "channel": channel.model_dump(),
+            },
+            to=f"channel:{channel.id}",
+        )
+
+        return True
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+
+############################
+# RemoveReactionById
+############################
+
+
+@router.post("/{id}/messages/{message_id}/reactions/remove", response_model=bool)
+async def remove_reaction_by_id_and_user_id_and_name(
+    id: str, message_id: str, form_data: ReactionForm, user=Depends(get_verified_user)
+):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="read", access_control=channel.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    message = Messages.get_message_by_id(message_id)
+    if not message:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if message.channel_id != id:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    try:
+        Messages.remove_reaction_by_id_and_user_id_and_name(
+            message_id, user.id, form_data.name
+        )
+
+        message = Messages.get_message_by_id(message_id)
+        await sio.emit(
+            "channel-events",
+            {
+                "channel_id": channel.id,
+                "message_id": message.id,
+                "data": {
+                    "type": "message:reaction",
+                    "data": {
+                        **message.model_dump(),
+                        "user": UserNameResponse(**user.model_dump()).model_dump(),
+                        "name": form_data.name,
+                    },
+                },
+                "user": UserNameResponse(**user.model_dump()).model_dump(),
+                "channel": channel.model_dump(),
+            },
+            to=f"channel:{channel.id}",
+        )
+
+        return True
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+
 ############################
 # DeleteMessageById
 ############################

+ 71 - 0
src/lib/apis/channels/index.ts

@@ -285,6 +285,77 @@ export const updateMessage = async (
 	return res;
 };
 
+export const addReaction = async (token: string = '', channel_id: string, message_id: string, name: string) => {
+	let error = null;
+
+	const res = await fetch(
+		`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/reactions/add`,
+		{
+			method: 'POST',
+			headers: {
+				Accept: 'application/json',
+				'Content-Type': 'application/json',
+				authorization: `Bearer ${token}`
+			},
+			body: JSON.stringify({ name })
+		}
+	)
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+}
+
+
+export const removeReaction = async (token: string = '', channel_id: string, message_id: string, name: string) => {
+	let error = null;
+
+	const res = await fetch(
+		`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/reactions/remove`,
+		{
+			method: 'POST',
+			headers: {
+				Accept: 'application/json',
+				'Content-Type': 'application/json',
+				authorization: `Bearer ${token}`
+			},
+			body: JSON.stringify({ name })
+		}
+	)
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+}
+
 export const deleteMessage = async (token: string = '', channel_id: string, message_id: string) => {
 	let error = null;
 

+ 8 - 1
src/lib/components/channel/Channel.svelte

@@ -1,13 +1,13 @@
 <script lang="ts">
 	import { toast } from 'svelte-sonner';
 	import { onDestroy, onMount, tick } from 'svelte';
+	import { goto } from '$app/navigation';
 
 	import { chatId, showSidebar, socket, user } from '$lib/stores';
 	import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels';
 
 	import Messages from './Messages.svelte';
 	import MessageInput from './MessageInput.svelte';
-	import { goto } from '$app/navigation';
 	import Navbar from './Navbar.svelte';
 
 	export let id = '';
@@ -84,6 +84,13 @@
 			} else if (type === 'message:delete') {
 				console.log('message:delete', data);
 				messages = messages.filter((message) => message.id !== data.id);
+			} else if (type === 'message:reaction') {
+				console.log('message:reaction', data);
+
+				const idx = messages.findIndex((message) => message.id === data.id);
+				if (idx !== -1) {
+					messages[idx] = data;
+				}
 			} else if (type === 'typing') {
 				if (event.user.id === $user.id) {
 					return;

+ 27 - 2
src/lib/components/channel/Messages.svelte

@@ -11,12 +11,12 @@
 	dayjs.extend(isYesterday);
 	import { tick, getContext, onMount, createEventDispatcher } from 'svelte';
 
-	import { settings } from '$lib/stores';
+	import { settings, user } from '$lib/stores';
 
 	import Message from './Messages/Message.svelte';
 	import Loader from '../common/Loader.svelte';
 	import Spinner from '../common/Spinner.svelte';
-	import { deleteMessage, updateMessage } from '$lib/apis/channels';
+	import { addReaction, deleteMessage, removeReaction, updateMessage } from '$lib/apis/channels';
 
 	const i18n = getContext('i18n');
 
@@ -109,6 +109,31 @@
 						return null;
 					});
 				}}
+				onReaction={(name) => {
+					if (
+						message.reactions
+							.find((reaction) => reaction.name === name)
+							?.user_ids?.includes($user.id) ??
+						false
+					) {
+						const res = removeReaction(
+							localStorage.token,
+							message.channel_id,
+							message.id,
+							name
+						).catch((error) => {
+							toast.error(error);
+							return null;
+						});
+					} else {
+						const res = addReaction(localStorage.token, message.channel_id, message.id, name).catch(
+							(error) => {
+								toast.error(error);
+								return null;
+							}
+						);
+					}
+				}}
 			/>
 		{/each}
 

+ 47 - 41
src/lib/components/channel/Messages/Message.svelte

@@ -35,25 +35,14 @@
 
 	export let onDelete: Function = () => {};
 	export let onEdit: Function = () => {};
+	export let onReaction: Function = () => {};
+
 	let showButtons = false;
 
 	let edit = false;
 	let editedContent = null;
 	let showDeleteConfirmDialog = false;
 
-	let reactions = [
-		{
-			name: 'red_circle',
-			user_ids: ['U07KUHZSYER'],
-			count: 1
-		},
-		{
-			name: '+1',
-			user_ids: [$user.id],
-			count: 1
-		}
-	];
-
 	const formatDate = (inputDate) => {
 		const date = dayjs(inputDate);
 		const now = dayjs();
@@ -92,7 +81,13 @@
 				<div
 					class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-800"
 				>
-					<ReactionPicker onClose={() => (showButtons = false)}>
+					<ReactionPicker
+						onClose={() => (showButtons = false)}
+						onSubmit={(name) => {
+							showButtons = false;
+							onReaction(name);
+						}}
+					>
 						<Tooltip content={$i18n.t('Add Reaction')}>
 							<button
 								class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
@@ -271,38 +266,49 @@
 							>{/if}
 					</div>
 
-					{#if reactions.length > 0}
+					{#if message.reactions.length > 0}
 						<div>
 							<div class="flex items-center gap-1 mt-1 mb-2">
-								{#each reactions as reaction}
-									<button
-										class="flex items-center gap-1.5 transition rounded-xl px-2 py-1 cursor-pointer {reaction.user_ids.includes(
-											$user.id
-										)
-											? ' bg-blue-500/10 outline outline-blue-500/50 outline-1'
-											: 'bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1'}"
-									>
-										{#if $shortCodesToEmojis[reaction.name]}
-											<img
-												src="/assets/emojis/{$shortCodesToEmojis[reaction.name].toLowerCase()}.svg"
-												alt={reaction.name}
-												class=" size-4"
-											/>
-										{:else}
-											<div>
-												{reaction.name}
-											</div>
-										{/if}
+								{#each message.reactions as reaction}
+									<Tooltip content={`:${reaction.name}:`}>
+										<button
+											class="flex items-center gap-1.5 transition rounded-xl px-2 py-1 cursor-pointer {reaction.user_ids.includes(
+												$user.id
+											)
+												? ' bg-blue-500/10 outline outline-blue-500/50 outline-1'
+												: 'bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1'}"
+											on:click={() => {
+												onReaction(reaction.name);
+											}}
+										>
+											{#if $shortCodesToEmojis[reaction.name]}
+												<img
+													src="/assets/emojis/{$shortCodesToEmojis[
+														reaction.name
+													].toLowerCase()}.svg"
+													alt={reaction.name}
+													class=" size-4"
+												/>
+											{:else}
+												<div>
+													{reaction.name}
+												</div>
+											{/if}
 
-										{#if reaction.user_ids.length > 0}
-											<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
-												{reaction.user_ids?.length}
-											</div>
-										{/if}
-									</button>
+											{#if reaction.user_ids.length > 0}
+												<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
+													{reaction.user_ids?.length}
+												</div>
+											{/if}
+										</button>
+									</Tooltip>
 								{/each}
 
-								<ReactionPicker>
+								<ReactionPicker
+									onSubmit={(name) => {
+										onReaction(name);
+									}}
+								>
 									<Tooltip content={$i18n.t('Add Reaction')}>
 										<div
 											class="flex items-center gap-1.5 bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1 transition rounded-xl px-1 py-1 cursor-pointer text-gray-500 dark:text-gray-400"

+ 10 - 2
src/lib/components/channel/Messages/Message/ReactionPicker.svelte

@@ -7,6 +7,7 @@
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 
 	export let onClose = () => {};
+	export let onSubmit = (name) => {};
 	export let side = 'top';
 	export let align = 'start';
 
@@ -95,8 +96,15 @@
 													.join(', ')}
 												placement="top"
 											>
-												<div
+												<button
 													class="p-1.5 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition"
+													on:click={() => {
+														typeof emojiShortCodes[emoji] === 'string'
+															? onSubmit(emojiShortCodes[emoji])
+															: onSubmit(emojiShortCodes[emoji][0]);
+
+														show = false;
+													}}
 												>
 													<img
 														src="/assets/emojis/{emoji.toLowerCase()}.svg"
@@ -104,7 +112,7 @@
 														class="size-5"
 														loading="lazy"
 													/>
-												</div>
+												</button>
 											</Tooltip>
 										{/each}
 									</div>