Add multi-user auth, background generation, and chat UX improvements
Phase 5: Multi-user authentication with session cookies, bcrypt passwords, first-user-is-admin pattern, per-user data isolation, backup/restore, Docker Swarm production stack with secrets and network isolation. Phase 5.1: Chat UX improvements: - Background generation architecture (GenerationBuffer + asyncio task) with SSE fan-out, reconnect support, and periodic DB flushes - LLM-generated conversation titles (first exchange + every 10th message) - Stop generation button with cancel_event and partial content preservation - Relative timestamps in sidebar (5m ago, 3h ago, then dates) - Empty chat auto-cleanup on navigation away - Save-as-note uses LLM for title generation, tags notes with "chat" - Summarize-as-note also tags with "chat" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
import type { User, AuthStatus } from "@/types/auth";
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const user = ref<User | null>(null);
|
||||
const loading = ref(true);
|
||||
const hasUsers = ref(true);
|
||||
|
||||
const isAuthenticated = computed(() => user.value !== null);
|
||||
const isAdmin = computed(() => user.value?.role === "admin");
|
||||
|
||||
async function checkAuth() {
|
||||
loading.value = true;
|
||||
try {
|
||||
user.value = await apiGet<User>("/api/auth/me");
|
||||
} catch {
|
||||
user.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHasUsers() {
|
||||
try {
|
||||
const data = await apiGet<AuthStatus>("/api/auth/status");
|
||||
hasUsers.value = data.has_users;
|
||||
} catch {
|
||||
hasUsers.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
user.value = await apiPost<User>("/api/auth/login", { username, password });
|
||||
}
|
||||
|
||||
async function register(username: string, password: string, email?: string) {
|
||||
user.value = await apiPost<User>("/api/auth/register", {
|
||||
username,
|
||||
password,
|
||||
email: email || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await apiPost("/api/auth/logout", {});
|
||||
user.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
hasUsers,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
checkAuth,
|
||||
checkHasUsers,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
+153
-58
@@ -5,8 +5,9 @@ import {
|
||||
apiPost,
|
||||
apiPatch,
|
||||
apiDelete,
|
||||
apiStreamPost,
|
||||
apiSSEStream,
|
||||
} from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationDetail,
|
||||
@@ -14,6 +15,7 @@ import type {
|
||||
Message,
|
||||
OllamaModel,
|
||||
OllamaStatus,
|
||||
SendMessageResponse,
|
||||
} from "@/types/chat";
|
||||
|
||||
export const useChatStore = defineStore("chat", () => {
|
||||
@@ -42,6 +44,9 @@ export const useChatStore = defineStore("chat", () => {
|
||||
);
|
||||
conversations.value = data.conversations;
|
||||
total.value = data.total;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load conversations", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -51,12 +56,17 @@ export const useChatStore = defineStore("chat", () => {
|
||||
title = "",
|
||||
model = ""
|
||||
): Promise<Conversation> {
|
||||
const conv = await apiPost<Conversation>("/api/chat/conversations", {
|
||||
title,
|
||||
model,
|
||||
});
|
||||
conversations.value.unshift(conv);
|
||||
return conv;
|
||||
try {
|
||||
const conv = await apiPost<Conversation>("/api/chat/conversations", {
|
||||
title,
|
||||
model,
|
||||
});
|
||||
conversations.value.unshift(conv);
|
||||
return conv;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to create conversation", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConversation(id: number) {
|
||||
@@ -65,30 +75,43 @@ export const useChatStore = defineStore("chat", () => {
|
||||
currentConversation.value = await apiGet<ConversationDetail>(
|
||||
`/api/chat/conversations/${id}`
|
||||
);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load conversation", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteConversation(id: number) {
|
||||
await apiDelete(`/api/chat/conversations/${id}`);
|
||||
conversations.value = conversations.value.filter((c) => c.id !== id);
|
||||
if (currentConversation.value?.id === id) {
|
||||
currentConversation.value = null;
|
||||
try {
|
||||
await apiDelete(`/api/chat/conversations/${id}`);
|
||||
conversations.value = conversations.value.filter((c) => c.id !== id);
|
||||
if (currentConversation.value?.id === id) {
|
||||
currentConversation.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to delete conversation", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTitle(id: number, title: string) {
|
||||
const updated = await apiPatch<Conversation>(
|
||||
`/api/chat/conversations/${id}`,
|
||||
{ title }
|
||||
);
|
||||
const idx = conversations.value.findIndex((c) => c.id === id);
|
||||
if (idx !== -1) {
|
||||
conversations.value[idx] = { ...conversations.value[idx], ...updated };
|
||||
}
|
||||
if (currentConversation.value?.id === id) {
|
||||
currentConversation.value.title = updated.title;
|
||||
try {
|
||||
const updated = await apiPatch<Conversation>(
|
||||
`/api/chat/conversations/${id}`,
|
||||
{ title }
|
||||
);
|
||||
const idx = conversations.value.findIndex((c) => c.id === id);
|
||||
if (idx !== -1) {
|
||||
conversations.value[idx] = { ...conversations.value[idx], ...updated };
|
||||
}
|
||||
if (currentConversation.value?.id === id) {
|
||||
currentConversation.value.title = updated.title;
|
||||
}
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to update title", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,53 +139,124 @@ export const useChatStore = defineStore("chat", () => {
|
||||
streaming.value = true;
|
||||
streamingContent.value = "";
|
||||
|
||||
// Phase 1: POST to start generation
|
||||
let assistantMessageId: number;
|
||||
try {
|
||||
await apiStreamPost(
|
||||
const resp = await apiPost<SendMessageResponse>(
|
||||
`/api/chat/conversations/${convId}/messages`,
|
||||
{
|
||||
content,
|
||||
context_note_id: contextNoteId,
|
||||
exclude_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
|
||||
},
|
||||
(data) => {
|
||||
if (data.context) {
|
||||
lastContextMeta.value = data.context as ContextMeta;
|
||||
}
|
||||
if (data.chunk) {
|
||||
streamingContent.value += data.chunk as string;
|
||||
}
|
||||
if (data.done) {
|
||||
// Add the final assistant message
|
||||
const assistantMsg: Message = {
|
||||
id: data.message_id as number,
|
||||
conversation_id: convId,
|
||||
role: "assistant",
|
||||
content: streamingContent.value,
|
||||
context_note_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
if (currentConversation.value?.id === convId) {
|
||||
currentConversation.value.messages.push(assistantMsg);
|
||||
}
|
||||
streamingContent.value = "";
|
||||
streaming.value = false;
|
||||
|
||||
// Update conversation in list
|
||||
const idx = conversations.value.findIndex((c) => c.id === convId);
|
||||
if (idx !== -1) {
|
||||
conversations.value[idx].message_count += 2;
|
||||
conversations.value[idx].updated_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
if (data.error) {
|
||||
streaming.value = false;
|
||||
streamingContent.value = "";
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
assistantMessageId = resp.assistant_message_id;
|
||||
} catch (e) {
|
||||
streaming.value = false;
|
||||
streamingContent.value = "";
|
||||
useToastStore().show("Failed to send message", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: Connect to SSE stream with reconnection
|
||||
await _streamGeneration(convId, assistantMessageId);
|
||||
}
|
||||
|
||||
async function _streamGeneration(
|
||||
convId: number,
|
||||
assistantMessageId: number,
|
||||
initialLastEventId = -1,
|
||||
attempt = 0,
|
||||
) {
|
||||
const MAX_RETRIES = 5;
|
||||
let lastEventId = initialLastEventId;
|
||||
let gotDone = false;
|
||||
|
||||
const handle = apiSSEStream(
|
||||
`/api/chat/conversations/${convId}/generation/stream` +
|
||||
(lastEventId >= 0 ? `?last_event_id=${lastEventId}` : ""),
|
||||
(event) => {
|
||||
lastEventId = event.id;
|
||||
|
||||
switch (event.event) {
|
||||
case "context":
|
||||
lastContextMeta.value = event.data.context as ContextMeta;
|
||||
break;
|
||||
case "chunk":
|
||||
streamingContent.value += event.data.chunk as string;
|
||||
break;
|
||||
case "done":
|
||||
gotDone = true;
|
||||
{
|
||||
const assistantMsg: Message = {
|
||||
id: event.data.message_id as number,
|
||||
conversation_id: convId,
|
||||
role: "assistant",
|
||||
content: streamingContent.value,
|
||||
context_note_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
if (currentConversation.value?.id === convId) {
|
||||
currentConversation.value.messages.push(assistantMsg);
|
||||
}
|
||||
streamingContent.value = "";
|
||||
streaming.value = false;
|
||||
|
||||
// Update conversation in list
|
||||
const idx = conversations.value.findIndex((c) => c.id === convId);
|
||||
if (idx !== -1) {
|
||||
conversations.value[idx].message_count += 2;
|
||||
conversations.value[idx].updated_at = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
gotDone = true;
|
||||
streaming.value = false;
|
||||
streamingContent.value = "";
|
||||
useToastStore().show(
|
||||
"Chat error: " + (event.data.error as string),
|
||||
"error",
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for the stream to close (events are processed via callback above)
|
||||
await handle.done;
|
||||
|
||||
// If stream ended without done/error, attempt reconnection
|
||||
if (!gotDone && attempt < MAX_RETRIES) {
|
||||
const delay = Math.min(1000 * 2 ** attempt, 10_000);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
if (currentConversation.value?.id === convId) {
|
||||
return _streamGeneration(convId, assistantMessageId, lastEventId, attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Recovery fallback: re-fetch conversation from DB
|
||||
if (!gotDone && currentConversation.value?.id === convId) {
|
||||
try {
|
||||
await fetchConversation(convId);
|
||||
} catch {
|
||||
// Re-fetch failed — partial content visible on next page load
|
||||
}
|
||||
}
|
||||
|
||||
streaming.value = false;
|
||||
streamingContent.value = "";
|
||||
}
|
||||
|
||||
async function cancelGeneration() {
|
||||
if (!currentConversation.value) return;
|
||||
try {
|
||||
await apiPost(
|
||||
`/api/chat/conversations/${currentConversation.value.id}/generation/cancel`,
|
||||
{}
|
||||
);
|
||||
} catch {
|
||||
// Generation may have already finished — ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +327,7 @@ export const useChatStore = defineStore("chat", () => {
|
||||
deleteConversation,
|
||||
updateTitle,
|
||||
sendMessage,
|
||||
cancelGeneration,
|
||||
saveMessageAsNote,
|
||||
summarizeAsNote,
|
||||
fetchModels,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { Note, NoteListResponse } from "@/types/note";
|
||||
|
||||
export const useNotesStore = defineStore("notes", () => {
|
||||
@@ -36,6 +37,9 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
);
|
||||
notes.value = data.notes;
|
||||
total.value = data.total;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load notes", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -62,6 +66,9 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
currentNote.value = await apiGet<Note>(`/api/notes/${id}`);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load note", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -71,26 +78,40 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
title: string;
|
||||
body: string;
|
||||
}): Promise<Note> {
|
||||
const note = await apiPost<Note>("/api/notes", data);
|
||||
return note;
|
||||
try {
|
||||
return await apiPost<Note>("/api/notes", data);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to create note", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNote(
|
||||
id: number,
|
||||
data: Partial<Pick<Note, "title" | "body">>
|
||||
): Promise<Note> {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = note;
|
||||
try {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = note;
|
||||
}
|
||||
return note;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to update note", "error");
|
||||
throw e;
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
async function deleteNote(id: number) {
|
||||
await apiDelete(`/api/notes/${id}`);
|
||||
notes.value = notes.value.filter((n) => n.id !== id);
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = null;
|
||||
try {
|
||||
await apiDelete(`/api/notes/${id}`);
|
||||
notes.value = notes.value.filter((n) => n.id !== id);
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to delete note", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,26 +164,35 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
}
|
||||
|
||||
async function convertToTask(noteId: number): Promise<Note> {
|
||||
const note = await apiPost<Note>(
|
||||
`/api/notes/${noteId}/convert-to-task`,
|
||||
{}
|
||||
);
|
||||
// Update local state — the note is now a task
|
||||
if (currentNote.value?.id === noteId) {
|
||||
currentNote.value = note;
|
||||
try {
|
||||
const note = await apiPost<Note>(
|
||||
`/api/notes/${noteId}/convert-to-task`,
|
||||
{}
|
||||
);
|
||||
if (currentNote.value?.id === noteId) {
|
||||
currentNote.value = note;
|
||||
}
|
||||
return note;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to convert to task", "error");
|
||||
throw e;
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
async function convertToNote(noteId: number): Promise<Note> {
|
||||
const note = await apiPost<Note>(
|
||||
`/api/notes/${noteId}/convert-to-note`,
|
||||
{}
|
||||
);
|
||||
if (currentNote.value?.id === noteId) {
|
||||
currentNote.value = note;
|
||||
try {
|
||||
const note = await apiPost<Note>(
|
||||
`/api/notes/${noteId}/convert-to-note`,
|
||||
{}
|
||||
);
|
||||
if (currentNote.value?.id === noteId) {
|
||||
currentNote.value = note;
|
||||
}
|
||||
return note;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to convert to note", "error");
|
||||
throw e;
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
async function fetchBacklinks(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { apiGet, apiPut, apiPost, apiStreamPost } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { AppSettings } from "@/types/settings";
|
||||
|
||||
export const useSettingsStore = defineStore("settings", () => {
|
||||
@@ -31,6 +32,9 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
settings.value = await apiPut<AppSettings>("/api/settings", updates);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to save settings", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -55,8 +59,13 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
}
|
||||
|
||||
async function deleteModel(model: string) {
|
||||
await apiPost("/api/chat/models/delete", { model });
|
||||
await fetchInstalledModels();
|
||||
try {
|
||||
await apiPost("/api/chat/models/delete", { model });
|
||||
await fetchInstalledModels();
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to delete model", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { Task, TaskListResponse, TaskStatus, TaskPriority } from "@/types/task";
|
||||
|
||||
export const useTasksStore = defineStore("tasks", () => {
|
||||
@@ -41,6 +42,9 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
);
|
||||
tasks.value = data.tasks;
|
||||
total.value = data.total;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load tasks", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -50,6 +54,9 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
currentTask.value = await apiGet<Task>(`/api/tasks/${id}`);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load task", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -62,7 +69,12 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
priority?: TaskPriority;
|
||||
due_date?: string | null;
|
||||
}): Promise<Task> {
|
||||
return await apiPost<Task>("/api/tasks", data);
|
||||
try {
|
||||
return await apiPost<Task>("/api/tasks", data);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to create task", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTask(
|
||||
@@ -71,31 +83,45 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
Pick<Task, "title" | "body" | "status" | "priority" | "due_date">
|
||||
>
|
||||
): Promise<Task> {
|
||||
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = task;
|
||||
try {
|
||||
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = task;
|
||||
}
|
||||
return task;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to update task", "error");
|
||||
throw e;
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
async function patchStatus(id: number, status: TaskStatus): Promise<Task> {
|
||||
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
|
||||
// Update in list if present
|
||||
const idx = tasks.value.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) {
|
||||
tasks.value[idx] = task;
|
||||
try {
|
||||
const task = await apiPatch<Task>(`/api/tasks/${id}/status`, { status });
|
||||
const idx = tasks.value.findIndex((t) => t.id === id);
|
||||
if (idx !== -1) {
|
||||
tasks.value[idx] = task;
|
||||
}
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = task;
|
||||
}
|
||||
return task;
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to update task status", "error");
|
||||
throw e;
|
||||
}
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = task;
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
async function deleteTask(id: number) {
|
||||
await apiDelete(`/api/tasks/${id}`);
|
||||
tasks.value = tasks.value.filter((t) => t.id !== id);
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = null;
|
||||
try {
|
||||
await apiDelete(`/api/tasks/${id}`);
|
||||
tasks.value = tasks.value.filter((t) => t.id !== id);
|
||||
if (currentTask.value?.id === id) {
|
||||
currentTask.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to delete task", "error");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defineStore } from "pinia";
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
type: "success" | "error";
|
||||
type: "success" | "error" | "warning";
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
@@ -12,13 +12,17 @@ let nextId = 0;
|
||||
export const useToastStore = defineStore("toast", () => {
|
||||
const toasts = ref<Toast[]>([]);
|
||||
|
||||
function show(message: string, type: "success" | "error" = "success") {
|
||||
function show(message: string, type: "success" | "error" | "warning" = "success") {
|
||||
const id = nextId++;
|
||||
toasts.value.push({ id, message, type });
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}, 3000);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
return { toasts, show };
|
||||
function dismiss(id: number) {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
return { toasts, show, dismiss };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user