699 lines
17 KiB
Svelte
699 lines
17 KiB
Svelte
<script context="module" lang="ts">
|
||
/** Shared 1×1 transparent drag preview; avoids one Image per sidebar row */
|
||
const invisibleDragImage = new Image();
|
||
invisibleDragImage.src =
|
||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||
</script>
|
||
|
||
<script lang="ts">
|
||
import { toast } from 'svelte-sonner';
|
||
import { goto, invalidate, invalidateAll } from '$app/navigation';
|
||
import { onMount, getContext, createEventDispatcher, tick } from 'svelte';
|
||
const i18n = getContext('i18n');
|
||
|
||
const dispatch = createEventDispatcher();
|
||
|
||
import {
|
||
archiveChatById,
|
||
cloneChatById,
|
||
deleteChatById,
|
||
getAllTags,
|
||
getChatById,
|
||
getChatList,
|
||
getChatListByTagName,
|
||
getPinnedChatList,
|
||
updateChatById,
|
||
updateChatFolderIdById
|
||
} from '$lib/apis/chats';
|
||
import {
|
||
chatId,
|
||
chatTitle as _chatTitle,
|
||
chats,
|
||
mobile,
|
||
pinnedChats,
|
||
showSidebar,
|
||
currentChatPage,
|
||
tags,
|
||
selectedFolder,
|
||
activeChatIds
|
||
} from '$lib/stores';
|
||
|
||
import ChatMenu from './ChatMenu.svelte';
|
||
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||
import ShareChatModal from '$lib/components/chat/ShareChatModal.svelte';
|
||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
|
||
import DragGhost from '$lib/components/common/DragGhost.svelte';
|
||
import Check from '$lib/components/icons/Check.svelte';
|
||
import XMark from '$lib/components/icons/XMark.svelte';
|
||
import Document from '$lib/components/icons/Document.svelte';
|
||
import Sparkles from '$lib/components/icons/Sparkles.svelte';
|
||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||
import { generateTitle } from '$lib/apis';
|
||
import { createMessagesList } from '$lib/utils';
|
||
|
||
export let className = '';
|
||
|
||
export let id;
|
||
export let title;
|
||
export let createdAt: number | null = null;
|
||
export let updatedAt: number | null = null;
|
||
export let lastReadAt: number | null = null;
|
||
|
||
export let selected = false;
|
||
export let shiftKey = false;
|
||
export let readonly = false;
|
||
|
||
export let onDragEnd = () => {};
|
||
|
||
function formatTimeAgo(timestamp: number): string {
|
||
const now = Date.now();
|
||
const diff = now - timestamp * 1000; // timestamp is in seconds
|
||
|
||
const seconds = Math.floor(diff / 1000);
|
||
const minutes = Math.floor(seconds / 60);
|
||
const hours = Math.floor(minutes / 60);
|
||
const days = Math.floor(hours / 24);
|
||
const weeks = Math.floor(days / 7);
|
||
const years = Math.floor(days / 365);
|
||
|
||
if (years > 0) return $i18n.t('{{COUNT}}y', { COUNT: years, context: 'time_ago' });
|
||
if (weeks > 0) return $i18n.t('{{COUNT}}w', { COUNT: weeks, context: 'time_ago' });
|
||
if (days > 0) return $i18n.t('{{COUNT}}d', { COUNT: days, context: 'time_ago' });
|
||
if (hours > 0) return $i18n.t('{{COUNT}}h', { COUNT: hours, context: 'time_ago' });
|
||
if (minutes > 0) return $i18n.t('{{COUNT}}m', { COUNT: minutes, context: 'time_ago' });
|
||
return $i18n.t('1m', { context: 'time_ago' });
|
||
}
|
||
|
||
let chat = null;
|
||
|
||
let mouseOver = false;
|
||
|
||
// Local state: tracks the last updatedAt seen while the user was viewing
|
||
// this chat. Survives prop refreshes from sidebar data re-fetches that
|
||
// would overwrite the `lastReadAt` prop with a stale server value.
|
||
let viewedAt: number | null = null;
|
||
|
||
$: if (id === $chatId) {
|
||
viewedAt = updatedAt ?? Date.now() / 1000;
|
||
}
|
||
|
||
$: effectiveReadAt = Math.max(lastReadAt ?? 0, viewedAt ?? 0) || null;
|
||
|
||
$: unread =
|
||
id !== $chatId &&
|
||
!$activeChatIds.has(id) &&
|
||
(effectiveReadAt === null || (updatedAt !== null && updatedAt > effectiveReadAt));
|
||
|
||
const loadChat = async () => {
|
||
if (!chat) {
|
||
draggable = false;
|
||
chat = await getChatById(localStorage.token, id);
|
||
draggable = true;
|
||
}
|
||
};
|
||
|
||
let showShareChatModal = false;
|
||
let confirmEdit = false;
|
||
|
||
let chatTitle = title;
|
||
|
||
const editChatTitle = async (id, title) => {
|
||
if (title === '') {
|
||
toast.error($i18n.t('Title cannot be an empty string.'));
|
||
} else {
|
||
await updateChatById(localStorage.token, id, {
|
||
title: title
|
||
});
|
||
|
||
if (id === $chatId) {
|
||
_chatTitle.set(title);
|
||
}
|
||
|
||
currentChatPage.set(1);
|
||
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||
|
||
dispatch('change');
|
||
}
|
||
};
|
||
|
||
const cloneChatHandler = async (id) => {
|
||
const res = await cloneChatById(
|
||
localStorage.token,
|
||
id,
|
||
$i18n.t('Clone of {{TITLE}}', {
|
||
TITLE: title
|
||
})
|
||
).catch((error) => {
|
||
toast.error(`${error}`);
|
||
return null;
|
||
});
|
||
|
||
if (res) {
|
||
goto(`/c/${res.id}`);
|
||
|
||
currentChatPage.set(1);
|
||
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||
}
|
||
};
|
||
|
||
let deleting = false;
|
||
|
||
const deleteChatHandler = async (id) => {
|
||
if (deleting) return;
|
||
deleting = true;
|
||
|
||
const res = await deleteChatById(localStorage.token, id).catch((error) => {
|
||
toast.error(`${error}`);
|
||
return null;
|
||
});
|
||
|
||
if (res) {
|
||
tags.set(await getAllTags(localStorage.token));
|
||
if ($chatId === id) {
|
||
await goto('/');
|
||
|
||
await chatId.set('');
|
||
await tick();
|
||
}
|
||
|
||
dispatch('change');
|
||
}
|
||
|
||
deleting = false;
|
||
};
|
||
|
||
let archiving = false;
|
||
|
||
const archiveChatHandler = async (id) => {
|
||
if (archiving) return;
|
||
archiving = true;
|
||
|
||
try {
|
||
await archiveChatById(localStorage.token, id);
|
||
|
||
if ($chatId === id) {
|
||
await goto('/');
|
||
chatId.set('');
|
||
}
|
||
|
||
dispatch('change');
|
||
toast.success($i18n.t('Chat archived.'));
|
||
} catch (error) {
|
||
console.error('Error archiving chat:', error);
|
||
toast.error($i18n.t('Failed to archive chat.'));
|
||
} finally {
|
||
archiving = false;
|
||
}
|
||
};
|
||
|
||
const moveChatHandler = async (chatId, folderId) => {
|
||
if (chatId && folderId) {
|
||
const res = await updateChatFolderIdById(localStorage.token, chatId, folderId).catch(
|
||
(error) => {
|
||
toast.error(`${error}`);
|
||
return null;
|
||
}
|
||
);
|
||
|
||
if (res) {
|
||
currentChatPage.set(1);
|
||
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||
|
||
dispatch('change');
|
||
|
||
toast.success($i18n.t('Chat moved successfully'));
|
||
}
|
||
} else {
|
||
toast.error($i18n.t('Failed to move chat'));
|
||
}
|
||
};
|
||
|
||
let itemElement;
|
||
|
||
let generating = false;
|
||
|
||
let ignoreBlur = false;
|
||
let doubleClicked = false;
|
||
|
||
let dragged = false;
|
||
let x = 0;
|
||
let y = 0;
|
||
|
||
const onDragStart = (event) => {
|
||
event.stopPropagation();
|
||
|
||
event.dataTransfer.setDragImage(invisibleDragImage, 0, 0);
|
||
|
||
// Set the data to be transferred
|
||
event.dataTransfer.setData(
|
||
'text/plain',
|
||
JSON.stringify({
|
||
type: 'chat',
|
||
id: id
|
||
})
|
||
);
|
||
|
||
dragged = true;
|
||
itemElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged
|
||
};
|
||
|
||
const onDrag = (event) => {
|
||
event.stopPropagation();
|
||
|
||
x = event.clientX;
|
||
y = event.clientY;
|
||
};
|
||
|
||
const onDragEndHandler = (event) => {
|
||
event.stopPropagation();
|
||
|
||
itemElement.style.opacity = '1'; // Reset visual cue after drag
|
||
dragged = false;
|
||
|
||
onDragEnd(event);
|
||
};
|
||
|
||
const onClickOutside = (event) => {
|
||
if (!itemElement.contains(event.target)) {
|
||
if (confirmEdit) {
|
||
if (chatTitle !== title) {
|
||
editChatTitle(id, chatTitle);
|
||
}
|
||
|
||
confirmEdit = false;
|
||
chatTitle = '';
|
||
}
|
||
}
|
||
};
|
||
|
||
onMount(() => {
|
||
const el = itemElement;
|
||
if (!el) return;
|
||
|
||
document.addEventListener('click', onClickOutside, true);
|
||
el.addEventListener('dragstart', onDragStart);
|
||
el.addEventListener('drag', onDrag);
|
||
el.addEventListener('dragend', onDragEndHandler);
|
||
|
||
return () => {
|
||
document.removeEventListener('click', onClickOutside, true);
|
||
el.removeEventListener('dragstart', onDragStart);
|
||
el.removeEventListener('drag', onDrag);
|
||
el.removeEventListener('dragend', onDragEndHandler);
|
||
};
|
||
});
|
||
|
||
let showDeleteConfirm = false;
|
||
|
||
const chatTitleInputKeydownHandler = (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
setTimeout(() => {
|
||
const input = document.getElementById(`chat-title-input-${id}`);
|
||
if (input) input.blur();
|
||
}, 0);
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
confirmEdit = false;
|
||
chatTitle = '';
|
||
}
|
||
};
|
||
|
||
const renameHandler = async () => {
|
||
chatTitle = title;
|
||
confirmEdit = true;
|
||
|
||
await tick();
|
||
|
||
setTimeout(() => {
|
||
const input = document.getElementById(`chat-title-input-${id}`);
|
||
if (input) {
|
||
input.focus();
|
||
input.select();
|
||
}
|
||
}, 0);
|
||
};
|
||
|
||
const generateTitleHandler = async () => {
|
||
generating = true;
|
||
chat = await getChatById(localStorage.token, id);
|
||
|
||
const chatContent = chat.chat;
|
||
|
||
// Build the active branch message list from the history tree.
|
||
// Fallback to the legacy flat messages array for older chats
|
||
// that haven't been migrated to the tree structure yet.
|
||
const history = chatContent?.history;
|
||
let messages = [];
|
||
if (history?.messages && history?.currentId) {
|
||
messages = createMessagesList(history, history.currentId).map((message) => ({
|
||
role: message.role,
|
||
content: message.content
|
||
}));
|
||
} else {
|
||
messages = (chatContent?.messages ?? []).map((message) => ({
|
||
role: message.role,
|
||
content: message.content
|
||
}));
|
||
}
|
||
|
||
// Resolve the model from the most recent assistant message in the
|
||
// active branch. This avoids using the stale top-level `models`
|
||
// array which may reference a model from an older edit.
|
||
let model = '';
|
||
|
||
// For the active chat, prefer the live dropdown selection.
|
||
if (id === $chatId) {
|
||
try {
|
||
model = JSON.parse(sessionStorage.selectedModels || '[]').find((m) => m) ?? '';
|
||
} catch {}
|
||
}
|
||
|
||
if (!model && history?.messages && history?.currentId) {
|
||
let currentId = history.currentId;
|
||
while (currentId) {
|
||
const msg = history.messages[currentId];
|
||
if (!msg) break;
|
||
if (msg.role === 'assistant' && msg.model) {
|
||
model = msg.model;
|
||
break;
|
||
}
|
||
currentId = msg.parentId;
|
||
}
|
||
}
|
||
|
||
// Fallback to top-level models if no model was found in the history
|
||
if (!model) {
|
||
model = chatContent?.models?.at(0) ?? '';
|
||
}
|
||
|
||
chatTitle = '';
|
||
|
||
const generatedTitle = await generateTitle(localStorage.token, model, messages).catch(
|
||
(error) => {
|
||
toast.error(`${error}`);
|
||
return null;
|
||
}
|
||
);
|
||
|
||
if (generatedTitle) {
|
||
if (generatedTitle !== title) {
|
||
editChatTitle(id, generatedTitle);
|
||
}
|
||
|
||
confirmEdit = false;
|
||
} else {
|
||
chatTitle = title;
|
||
}
|
||
|
||
generating = false;
|
||
};
|
||
</script>
|
||
|
||
<ShareChatModal bind:show={showShareChatModal} chatId={id} />
|
||
|
||
<DeleteConfirmDialog
|
||
bind:show={showDeleteConfirm}
|
||
title={$i18n.t('Delete chat?')}
|
||
on:confirm={() => {
|
||
deleteChatHandler(id);
|
||
}}
|
||
>
|
||
<div class=" text-sm text-gray-500 flex-1 line-clamp-3">
|
||
{$i18n.t('This will delete')} <span class=" font-semibold">{title}</span>.
|
||
</div>
|
||
</DeleteConfirmDialog>
|
||
|
||
{#if dragged && x && y}
|
||
<DragGhost {x} {y}>
|
||
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-fit max-w-40">
|
||
<div class="flex items-center gap-1">
|
||
<Document className=" size-[18px]" strokeWidth="2" />
|
||
<div class=" text-xs text-white line-clamp-1">
|
||
{title}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</DragGhost>
|
||
{/if}
|
||
|
||
<div
|
||
id="sidebar-chat-group"
|
||
bind:this={itemElement}
|
||
class=" w-full {className} relative group"
|
||
draggable={!confirmEdit && !readonly}
|
||
>
|
||
{#if confirmEdit}
|
||
<div
|
||
id="sidebar-chat-item"
|
||
class=" w-full flex justify-between rounded-xl px-[11px] py-[6px] {id === $chatId ||
|
||
confirmEdit
|
||
? 'bg-gray-100 dark:bg-gray-900 selected'
|
||
: selected
|
||
? 'bg-gray-100 dark:bg-gray-950 selected'
|
||
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis relative {generating
|
||
? 'cursor-not-allowed'
|
||
: ''}"
|
||
>
|
||
<input
|
||
id="chat-title-input-{id}"
|
||
bind:value={chatTitle}
|
||
class=" bg-transparent w-full outline-hidden mr-10"
|
||
placeholder={generating ? $i18n.t('Generating...') : ''}
|
||
disabled={generating}
|
||
on:keydown={chatTitleInputKeydownHandler}
|
||
on:blur={async (e) => {
|
||
if (doubleClicked) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
await tick();
|
||
setTimeout(() => {
|
||
const input = document.getElementById(`chat-title-input-${id}`);
|
||
if (input) input.focus();
|
||
}, 0);
|
||
|
||
doubleClicked = false;
|
||
return;
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
{:else}
|
||
<a
|
||
id="sidebar-chat-item"
|
||
class=" w-full flex justify-between rounded-xl px-[11px] py-[6px] {id === $chatId ||
|
||
confirmEdit
|
||
? 'bg-gray-100 dark:bg-gray-900 selected'
|
||
: selected
|
||
? 'bg-gray-100 dark:bg-gray-950 selected'
|
||
: ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
|
||
href="/c/{id}"
|
||
on:click={() => {
|
||
dispatch('select');
|
||
|
||
if ($selectedFolder) {
|
||
selectedFolder.set(null);
|
||
}
|
||
|
||
if ($mobile) {
|
||
showSidebar.set(false);
|
||
}
|
||
|
||
// Optimistically mark as read in UI when clicked
|
||
unread = false;
|
||
lastReadAt = Date.now() / 1000;
|
||
}}
|
||
on:dblclick={async (e) => {
|
||
if (readonly) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
doubleClicked = true;
|
||
renameHandler();
|
||
}}
|
||
on:mouseenter={(e) => {
|
||
mouseOver = true;
|
||
}}
|
||
on:mouseleave={(e) => {
|
||
mouseOver = false;
|
||
}}
|
||
on:focus={(e) => {}}
|
||
draggable="false"
|
||
>
|
||
<!-- Loading spinner for active chat (left side) -->
|
||
{#if $activeChatIds.has(id)}
|
||
<div class="shrink-0 self-center pr-2">
|
||
<Spinner className="size-3" />
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="flex self-center flex-1 w-full min-w-0">
|
||
{#if unread}
|
||
<div class="shrink-0 self-center pr-2.5 flex transition-opacity duration-300">
|
||
<div class="size-1.5 bg-sky-500 rounded-full" />
|
||
</div>
|
||
{/if}
|
||
<div
|
||
dir="auto"
|
||
class="text-left self-center overflow-hidden w-full h-[20px] truncate {unread
|
||
? 'font-medium text-gray-900 dark:text-gray-100'
|
||
: ''}"
|
||
>
|
||
{title}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Time ago indicator -->
|
||
{#if createdAt && !mouseOver}
|
||
<div class="shrink-0 self-center text-[10px] text-gray-400 dark:text-gray-500 pl-2">
|
||
{formatTimeAgo(createdAt)}
|
||
</div>
|
||
{/if}
|
||
</a>
|
||
{/if}
|
||
|
||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||
{#if !readonly}
|
||
<div
|
||
id="sidebar-chat-item-menu"
|
||
class="
|
||
{id === $chatId || confirmEdit
|
||
? 'from-gray-100 dark:from-gray-900 selected'
|
||
: selected
|
||
? 'from-gray-100 dark:from-gray-950 selected'
|
||
: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
|
||
absolute {className === 'pr-2'
|
||
? 'right-[8px]'
|
||
: 'right-1'} top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-linear-to-l from-80%
|
||
|
||
to-transparent"
|
||
on:mouseenter={(e) => {
|
||
mouseOver = true;
|
||
}}
|
||
on:mouseleave={(e) => {
|
||
mouseOver = false;
|
||
}}
|
||
>
|
||
{#if confirmEdit}
|
||
<div
|
||
class="flex self-center items-center space-x-1.5 z-10 translate-y-[0.5px] -translate-x-[0.5px]"
|
||
>
|
||
<Tooltip content={$i18n.t('Generate')}>
|
||
<button
|
||
class=" self-center dark:hover:text-white transition disabled:cursor-not-allowed"
|
||
id="generate-title-button"
|
||
disabled={generating}
|
||
on:click={() => {
|
||
generateTitleHandler();
|
||
}}
|
||
>
|
||
<Sparkles strokeWidth="2" />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
{:else if shiftKey && mouseOver}
|
||
<div class=" flex items-center self-center space-x-1.5">
|
||
<Tooltip content={$i18n.t('Archive')} className="flex items-center">
|
||
<button
|
||
class=" self-center dark:hover:text-white transition disabled:cursor-not-allowed"
|
||
disabled={archiving}
|
||
on:click={() => {
|
||
archiveChatHandler(id);
|
||
}}
|
||
type="button"
|
||
>
|
||
<ArchiveBox className="size-4 translate-y-[0.5px]" strokeWidth="2" />
|
||
</button>
|
||
</Tooltip>
|
||
|
||
<Tooltip content={$i18n.t('Delete')}>
|
||
<button
|
||
class=" self-center dark:hover:text-white transition disabled:cursor-not-allowed"
|
||
disabled={deleting}
|
||
on:click={() => {
|
||
deleteChatHandler(id);
|
||
}}
|
||
type="button"
|
||
>
|
||
<GarbageBin strokeWidth="2" />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
{:else}
|
||
<div class="flex self-center z-10 items-end">
|
||
<ChatMenu
|
||
chatId={id}
|
||
cloneChatHandler={() => {
|
||
cloneChatHandler(id);
|
||
}}
|
||
shareHandler={() => {
|
||
showShareChatModal = true;
|
||
}}
|
||
{moveChatHandler}
|
||
archiveChatHandler={() => {
|
||
archiveChatHandler(id);
|
||
}}
|
||
{renameHandler}
|
||
deleteHandler={() => {
|
||
showDeleteConfirm = true;
|
||
}}
|
||
onClose={() => {
|
||
dispatch('unselect');
|
||
}}
|
||
onPinChange={async () => {
|
||
dispatch('change');
|
||
}}
|
||
>
|
||
<button
|
||
aria-label="Chat Menu"
|
||
class=" self-center dark:hover:text-white transition m-0"
|
||
on:click={() => {
|
||
dispatch('select');
|
||
}}
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 16 16"
|
||
fill="currentColor"
|
||
class="w-4 h-4"
|
||
>
|
||
<path
|
||
d="M2 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM6.5 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM12.5 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</ChatMenu>
|
||
|
||
{#if id === $chatId}
|
||
<!-- Shortcut support using "delete-chat-button" id -->
|
||
<button
|
||
id="delete-chat-button"
|
||
class="hidden"
|
||
on:click={() => {
|
||
showDeleteConfirm = true;
|
||
}}
|
||
>
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 16 16"
|
||
fill="currentColor"
|
||
class="w-4 h-4"
|
||
>
|
||
<path
|
||
d="M2 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM6.5 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM12.5 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|