mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-10 03:27:57 +00:00
refac
This commit is contained in:
parent
550311b218
commit
24ee1cb168
8 changed files with 215 additions and 139 deletions
|
|
@ -2,8 +2,9 @@ import time
|
|||
import uuid
|
||||
|
||||
from open_webui.internal.db import Base, get_async_db_context
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import JSON, BigInteger, Column, Text, select
|
||||
from sqlalchemy import JSON, BigInteger, Column, Text, select, update
|
||||
|
||||
|
||||
class SkillHistory(Base):
|
||||
|
|
@ -39,6 +40,35 @@ class SkillHistoryResponse(BaseModel):
|
|||
|
||||
|
||||
class SkillHistoryTable:
|
||||
async def delete_history_entry(self, skill_id, history_id, db=None):
|
||||
from open_webui.models.skills import Skill
|
||||
|
||||
async with get_async_db_context(db) as session:
|
||||
try:
|
||||
# Serialize with production switches on both SQLite and PostgreSQL.
|
||||
await session.execute(update(Skill).where(Skill.id == skill_id).values(version_id=Skill.version_id))
|
||||
skill = await session.get(Skill, skill_id, populate_existing=True)
|
||||
if not skill:
|
||||
return False
|
||||
if skill.version_id == history_id:
|
||||
raise HTTPException(400, 'Cannot delete the current version')
|
||||
entry = (
|
||||
await session.execute(select(SkillHistory).filter_by(id=history_id, skill_id=skill_id))
|
||||
).scalar_one_or_none()
|
||||
if not entry:
|
||||
return False
|
||||
await session.execute(
|
||||
update(SkillHistory)
|
||||
.where(SkillHistory.skill_id == skill_id, SkillHistory.parent_id == history_id)
|
||||
.values(parent_id=entry.parent_id)
|
||||
)
|
||||
await session.delete(entry)
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
def new_entry(self, skill_id, snapshot, user_id, parent_id=None, commit_message=None):
|
||||
return SkillHistory(
|
||||
id=str(uuid.uuid4()),
|
||||
|
|
|
|||
|
|
@ -408,7 +408,7 @@ class SkillsTable:
|
|||
snapshot['data'] = {'files': files}
|
||||
snapshot['content'] = next(f['content'] for f in files if f['path'] == 'SKILL.md')
|
||||
values = {'is_active': updated.get('is_active', skill.is_active)}
|
||||
if snapshot != old or updated.get('_restore'):
|
||||
if snapshot != old:
|
||||
entry = SkillHistories.new_entry(
|
||||
id, snapshot, user_id or skill.user_id, skill.version_id, updated.get('commit_message')
|
||||
)
|
||||
|
|
@ -437,6 +437,41 @@ class SkillsTable:
|
|||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def update_skill_version(
|
||||
self, id: str, version_id: str, expected_version_id: str, db=None
|
||||
) -> Optional[SkillModel]:
|
||||
async with get_async_db_context(db) as session:
|
||||
try:
|
||||
# Lock before reading the target revision so deletion cannot race promotion.
|
||||
await session.execute(update(Skill).where(Skill.id == id).values(version_id=Skill.version_id))
|
||||
skill = await session.get(Skill, id, populate_existing=True)
|
||||
if not skill:
|
||||
return None
|
||||
if expected_version_id != skill.version_id:
|
||||
raise HTTPException(409, {'code': 'version_conflict', 'current_version_id': skill.version_id})
|
||||
entry = await SkillHistories.get_history_by_id(id, version_id, db=session)
|
||||
if not entry:
|
||||
raise HTTPException(404, 'Skill version not found')
|
||||
snapshot = entry.snapshot
|
||||
result = await session.execute(
|
||||
update(Skill)
|
||||
.where(Skill.id == id, Skill.version_id == expected_version_id)
|
||||
.values(
|
||||
**{key: snapshot[key] for key in ('name', 'description', 'content', 'data', 'meta')},
|
||||
version_id=version_id,
|
||||
updated_at=int(time.time()),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise HTTPException(409, {'code': 'version_conflict'})
|
||||
await session.commit()
|
||||
await session.refresh(skill)
|
||||
return await self._to_skill_model(skill, db=session)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def toggle_skill_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[SkillModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -694,25 +694,33 @@ async def get_skill_history_entry(
|
|||
}
|
||||
|
||||
|
||||
class RestoreSkillForm(BaseModel):
|
||||
@router.delete('/id/{id}/history/{history_id}', response_model=bool)
|
||||
async def delete_skill_history_entry(
|
||||
id: str, history_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
await authorized_skill(id, user, 'write', db)
|
||||
if not await SkillHistories.delete_history_entry(id, history_id, db):
|
||||
raise HTTPException(404, 'Version not found')
|
||||
return True
|
||||
|
||||
|
||||
class SkillVersionUpdateForm(BaseModel):
|
||||
version_id: str
|
||||
expected_version_id: str
|
||||
commit_message: str | None = None
|
||||
|
||||
|
||||
@router.post('/id/{id}/history/{history_id}/restore')
|
||||
async def restore_skill(
|
||||
@router.post('/id/{id}/update/version', response_model=SkillModel | None)
|
||||
async def set_skill_version(
|
||||
id: str,
|
||||
history_id: str,
|
||||
request: Request,
|
||||
form_data: RestoreSkillForm,
|
||||
form_data: SkillVersionUpdateForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
skill = await authorized_skill(id, user, 'write', db)
|
||||
entry = await selected_history(skill, history_id, db)
|
||||
result = await Skills.update_skill_by_id(
|
||||
id, {**entry.snapshot, **form_data.model_dump(), '_restore': True}, db=db, user_id=user.id
|
||||
)
|
||||
await authorized_skill(id, user, 'write', db)
|
||||
result = await Skills.update_skill_version(id, form_data.version_id, form_data.expected_version_id, db=db)
|
||||
if not result:
|
||||
raise HTTPException(404, 'Skill not found')
|
||||
await publish_event(request, EVENTS.SKILL_UPDATED, actor=user, subject_id=id, data={'name': result.name})
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -370,16 +370,18 @@ export const getSkillHistory = async (token: string, id: string, page = 1) =>
|
|||
(await skillRequest(token, `/id/${id}/history?page=${page}`)).json();
|
||||
export const getSkillVersion = async (token: string, id: string, versionId: string) =>
|
||||
(await skillRequest(token, `/id/${id}/history/${versionId}`)).json();
|
||||
export const restoreSkillVersion = async (
|
||||
export const deleteSkillHistoryVersion = async (token: string, id: string, versionId: string) =>
|
||||
(await skillRequest(token, `/id/${id}/history/${versionId}`, { method: 'DELETE' })).json();
|
||||
export const setProductionSkillVersion = async (
|
||||
token: string,
|
||||
id: string,
|
||||
versionId: string,
|
||||
expectedVersionId: string
|
||||
) =>
|
||||
(
|
||||
await skillRequest(token, `/id/${id}/history/${versionId}/restore`, {
|
||||
await skillRequest(token, `/id/${id}/update/version`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ expected_version_id: expectedVersionId })
|
||||
body: JSON.stringify({ version_id: versionId, expected_version_id: expectedVersionId })
|
||||
})
|
||||
).json();
|
||||
export const cloneSkill = async (
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
import Clipboard from '$lib/components/icons/Clipboard.svelte';
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||
import AccessControlModal from '../common/AccessControlModal.svelte';
|
||||
import { user } from '$lib/stores';
|
||||
import { slugify, formatDate, copyToClipboard } from '$lib/utils';
|
||||
|
|
@ -31,7 +30,6 @@
|
|||
} from '$lib/apis/prompts';
|
||||
import dayjs from 'dayjs';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import PromptHistoryMenu from './PromptHistoryMenu.svelte';
|
||||
import Tags from '$lib/components/common/Tags.svelte';
|
||||
import VersionMenuItem from '../common/VersionMenuItem.svelte';
|
||||
import VersionDiff from '../common/VersionDiff.svelte';
|
||||
|
|
@ -79,6 +77,8 @@
|
|||
let metadataSave: Promise<void> = Promise.resolve();
|
||||
let suggestionTags: { name: string }[] = [];
|
||||
let showDiscard = false;
|
||||
let showDeleteVersion = false;
|
||||
let deleteVersionId: string | null = null;
|
||||
let discardAction: () => void = () => {};
|
||||
|
||||
$: selectedVersionId = selectedHistoryEntry?.id ?? prompt?.version_id;
|
||||
|
|
@ -258,7 +258,7 @@
|
|||
}
|
||||
};
|
||||
const handleDeleteHistory = async (historyId: string) => {
|
||||
if (disabled || loading) return;
|
||||
if (disabled || loading || historyId === prompt.version_id) return;
|
||||
loading = true;
|
||||
try {
|
||||
await deletePromptHistoryVersion(localStorage.token, prompt.id, historyId);
|
||||
|
|
@ -368,6 +368,21 @@
|
|||
onConfirm={discardAction}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteVersion}
|
||||
title={$i18n.t('Delete Version')}
|
||||
message={$i18n.t(
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent."
|
||||
) +
|
||||
(deleteVersionId === selectedVersionId && dirty
|
||||
? ' ' + $i18n.t('Unsaved changes to this version will be discarded.')
|
||||
: '')}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
onConfirm={() => {
|
||||
if (deleteVersionId) return handleDeleteHistory(deleteVersionId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden {modal
|
||||
? 'px-5 pt-3 pb-1'
|
||||
|
|
@ -520,6 +535,13 @@
|
|||
{entry}
|
||||
selected={selectedVersionId === entry.id}
|
||||
onSelect={() => chooseVersion(entry)}
|
||||
onDelete={disabled
|
||||
? undefined
|
||||
: () => {
|
||||
deleteVersionId = entry.id;
|
||||
showHistory = false;
|
||||
showDeleteVersion = true;
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{#if historyLoading}<div
|
||||
|
|
@ -552,20 +574,6 @@
|
|||
className="size-3.5"
|
||||
/>{/if}</button
|
||||
>
|
||||
{#if edit && !disabled && selectedVersionId && !editingHistory}
|
||||
<PromptHistoryMenu
|
||||
isProduction={!historical}
|
||||
onDelete={() => handleDeleteHistory(selectedVersionId)}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('More Options')}
|
||||
class="shrink-0 p-0.5 text-gray-500 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
><EllipsisHorizontal className="size-3.5" /></button
|
||||
>
|
||||
</PromptHistoryMenu>
|
||||
{/if}
|
||||
</div>
|
||||
<textarea
|
||||
bind:this={contentInput}
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let isProduction = false;
|
||||
export let onDelete: Function;
|
||||
export let onClose: Function;
|
||||
|
||||
let show = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
</script>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
title={$i18n.t('Delete Version')}
|
||||
message={$i18n.t(
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent."
|
||||
)}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
onConfirm={() => {
|
||||
onDelete();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
bind:show
|
||||
onOpenChange={(state) => {
|
||||
if (state === false) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip content={$i18n.t('More')}>
|
||||
<slot>
|
||||
<button
|
||||
class="p-1 rounded-lg text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
aria-label={$i18n.t('More Options')}
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</button>
|
||||
</slot>
|
||||
</Tooltip>
|
||||
|
||||
<div slot="content">
|
||||
<DropdownMenu className="min-w-[10.625rem]">
|
||||
{#if isProduction}
|
||||
<Tooltip content={$i18n.t('Cannot delete the production version')} placement="top">
|
||||
<div
|
||||
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] opacity-40 cursor-not-allowed"
|
||||
>
|
||||
<GarbageBin className="size-3.5" />
|
||||
<div class="flex items-center">{$i18n.t('Delete')}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
<button
|
||||
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:text-gray-900 dark:hover:text-gray-100"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
showDeleteConfirmDialog = true;
|
||||
}}
|
||||
>
|
||||
<GarbageBin className="size-3.5" />
|
||||
<div class="flex items-center">{$i18n.t('Delete')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
|
@ -25,8 +25,9 @@
|
|||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import {
|
||||
getSkillHistory,
|
||||
deleteSkillHistoryVersion,
|
||||
getSkillVersion,
|
||||
restoreSkillVersion,
|
||||
setProductionSkillVersion,
|
||||
getSkillById,
|
||||
createNewSkill,
|
||||
exportSkillBundle,
|
||||
|
|
@ -67,6 +68,8 @@
|
|||
let historyDiff: any = null;
|
||||
let comparing = false;
|
||||
let discard = false;
|
||||
let showDeleteVersion = false;
|
||||
let deleteVersionId: string | null = null;
|
||||
let discardAction = () => {};
|
||||
$: historical = edit && versionId !== currentVersionId;
|
||||
$: readOnly = disabled || historical;
|
||||
|
|
@ -105,6 +108,21 @@
|
|||
showHistory = false;
|
||||
if (selected !== versionId) confirmDiscard(() => selectVersion(selected));
|
||||
};
|
||||
const deleteVersion = async () => {
|
||||
if (disabled || loading || !deleteVersionId || deleteVersionId === currentVersionId) return;
|
||||
loading = true;
|
||||
try {
|
||||
await deleteSkillHistoryVersion(localStorage.token, id, deleteVersionId);
|
||||
if (versionId === deleteVersionId) await reload();
|
||||
historyPage = 1;
|
||||
await loadHistory();
|
||||
toast.success($i18n.t('Version deleted'));
|
||||
} catch (error) {
|
||||
toast.error(skillError(error));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
const compareToCurrent = async () => {
|
||||
if (comparing || historyDiff) return;
|
||||
const fromId = versionId!;
|
||||
|
|
@ -149,23 +167,27 @@
|
|||
toast.error(skillError(error));
|
||||
}
|
||||
};
|
||||
const restore = async () => {
|
||||
const setProductionVersion = async () => {
|
||||
if (disabled || !historical || loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
const restored = await restoreSkillVersion(
|
||||
const updated = await setProductionSkillVersion(
|
||||
localStorage.token,
|
||||
id,
|
||||
versionId!,
|
||||
currentVersionId!
|
||||
);
|
||||
currentVersionId = restored.version_id;
|
||||
versionId = restored.version_id;
|
||||
currentVersionId = updated.version_id;
|
||||
versionId = updated.version_id;
|
||||
historyDiff = null;
|
||||
remember();
|
||||
await loadHistory();
|
||||
toast.success($i18n.t('Saved'));
|
||||
toast.success($i18n.t('Production version updated'));
|
||||
} catch (error) {
|
||||
if ((error as any)?.code === 'version_conflict') conflict = true;
|
||||
toast.error(skillError(error));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
const exportVersion = async (format: 'json' | 'zip') => {
|
||||
|
|
@ -276,6 +298,16 @@
|
|||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteVersion}
|
||||
title={$i18n.t('Delete Version')}
|
||||
message={$i18n.t(
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent."
|
||||
)}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
onConfirm={deleteVersion}
|
||||
/>
|
||||
|
||||
<AccessControlModal
|
||||
bind:show={showAccessControlModal}
|
||||
bind:accessGrants
|
||||
|
|
@ -475,6 +507,13 @@
|
|||
{entry}
|
||||
selected={entry.id === versionId}
|
||||
onSelect={() => chooseVersion(entry.id)}
|
||||
onDelete={disabled
|
||||
? undefined
|
||||
: () => {
|
||||
deleteVersionId = entry.id;
|
||||
showHistory = false;
|
||||
showDeleteVersion = true;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
{#if historyPage > 1}
|
||||
|
|
@ -542,7 +581,7 @@
|
|||
{#if !disabled}<button
|
||||
type="button"
|
||||
class="flex h-7 items-center rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
|
||||
on:click={restore}>{$i18n.t('Restore as new version')}</button
|
||||
on:click={setProductionVersion}>{$i18n.t('Set as Production')}</button
|
||||
>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@
|
|||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
let showActions = false;
|
||||
|
||||
export let entry: {
|
||||
id: string;
|
||||
|
|
@ -15,6 +20,7 @@
|
|||
export let status = '';
|
||||
export let selected = false;
|
||||
export let onSelect: () => void;
|
||||
export let onDelete: (() => void) | undefined = undefined;
|
||||
const i18n = getContext<any>('i18n');
|
||||
$: authorId = entry?.user?.id || entry?.user_id;
|
||||
$: message = entry?.commit_message || entry?.id.slice(0, 7) || status;
|
||||
|
|
@ -24,26 +30,51 @@
|
|||
$: date = entry?.created_at ? new Date(entry.created_at * 1000) : null;
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-label={status || message}
|
||||
aria-checked={selected}
|
||||
class="text-left"
|
||||
title={entry
|
||||
? `${entry.id}${date ? ` · ${date.toLocaleString($i18n.language)}` : ''}`
|
||||
: undefined}
|
||||
on:click={onSelect}
|
||||
<div
|
||||
class="group flex h-[1.6875rem] w-full items-center rounded-xl hover:bg-gray-50/60 dark:hover:bg-gray-800/60"
|
||||
>
|
||||
<Tooltip as="span" className="flex shrink-0" content={author} allowHTML={false}>
|
||||
<ProfileImage
|
||||
src={authorId ? `/api/v1/users/${authorId}/profile/image` : '/user.png'}
|
||||
className="size-4 shrink-0 rounded-full!"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip as="span" className="block min-w-0 flex-1" content={message} allowHTML={false}>
|
||||
<span class="block truncate text-xs">{message}</span>
|
||||
</Tooltip>
|
||||
{#if status && entry}<span class="shrink-0 text-[0.625rem] text-gray-400">{status}</span>{/if}
|
||||
{#if selected}<Check className="size-3.5 shrink-0 text-blue-500" />{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-label={status || message}
|
||||
aria-checked={selected}
|
||||
class="flex h-full min-w-0 flex-1 items-center gap-2 rounded-xl px-2 text-left"
|
||||
title={entry
|
||||
? `${entry.id}${date ? ` · ${date.toLocaleString($i18n.language)}` : ''}`
|
||||
: undefined}
|
||||
on:click={onSelect}
|
||||
>
|
||||
<Tooltip as="span" className="flex shrink-0" content={author} allowHTML={false}>
|
||||
<ProfileImage
|
||||
src={authorId ? `/api/v1/users/${authorId}/profile/image` : '/user.png'}
|
||||
className="size-4 shrink-0 rounded-full!"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip as="span" className="block min-w-0 flex-1" content={message} allowHTML={false}>
|
||||
<span class="block truncate text-xs">{message}</span>
|
||||
</Tooltip>
|
||||
{#if status && entry}<span class="shrink-0 text-[0.625rem] text-gray-400">{status}</span>{/if}
|
||||
{#if selected}<Check className="size-3.5 shrink-0 text-blue-500" />{/if}
|
||||
</button>
|
||||
{#if onDelete}
|
||||
<Dropdown bind:show={showActions} align="end">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('More Options')}
|
||||
class="mr-0.5 flex size-6 shrink-0 items-center justify-center rounded-lg text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
><EllipsisHorizontal className="size-3.5" /></button
|
||||
>
|
||||
<div slot="content">
|
||||
<DropdownMenu className="min-w-32">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showActions = false;
|
||||
onDelete?.();
|
||||
}}><GarbageBin className="size-3.5" /><span>{$i18n.t('Delete')}</span></button
|
||||
>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue