initial commit
This commit is contained in:
243
frontend/src/components/chatManager.ts
Normal file
243
frontend/src/components/chatManager.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import { Marked } from 'marked';
|
||||
import { markedHighlight } from 'marked-highlight';
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
import {
|
||||
getSettings,
|
||||
getModels,
|
||||
sendMessage,
|
||||
getChatMessages,
|
||||
listChats,
|
||||
deleteChat,
|
||||
} from '../client';
|
||||
import { Chat, Message, MessageChunk, Model, Settings } from '../types';
|
||||
import { applyFilter } from '../utils';
|
||||
|
||||
const CHAT_ROUTE = '#/chats';
|
||||
const MODEL_KEY = 'aethera-chat-model';
|
||||
const IN_PROGRESS_UUID = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
// Markdown Renderer
|
||||
const marked = new Marked(
|
||||
markedHighlight({
|
||||
emptyLangClass: 'hljs',
|
||||
langPrefix: 'hljs language-',
|
||||
highlight(code, lang) {
|
||||
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
|
||||
return hljs.highlight(code, { language }).value;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Alpine.data('chatManager', () => ({
|
||||
chats: [] as Chat[],
|
||||
settings: {} as Settings,
|
||||
_models: [] as Model[],
|
||||
|
||||
selectedModel: '',
|
||||
inputMessage: '',
|
||||
error: '',
|
||||
|
||||
selectedChatID: null as string | null,
|
||||
chatListOpen: false,
|
||||
loading: false,
|
||||
|
||||
async init() {
|
||||
// Acquire Data
|
||||
this._models = await getModels();
|
||||
this.settings = await getSettings();
|
||||
this.selectedModel = localStorage.getItem(MODEL_KEY) || '';
|
||||
await this.loadChats();
|
||||
|
||||
// Route Chat
|
||||
const chatID = window.location.hash.split('/')[2];
|
||||
if (chatID) await this.selectChat(chatID);
|
||||
},
|
||||
|
||||
async loadChats() {
|
||||
try {
|
||||
const response = await listChats();
|
||||
this.chats = response.chats || [];
|
||||
} catch (err) {
|
||||
console.error('Error loading conversations:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteChat(event: Event, chatId: string) {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await deleteChat(chatId);
|
||||
|
||||
// Delete Chat
|
||||
const chatIndex = this.chats.findIndex((c) => c.id == chatId);
|
||||
this.chats.splice(chatIndex, 1);
|
||||
|
||||
// Update Index
|
||||
if (this.selectedChatID == chatId) {
|
||||
const newIndex = Math.min(chatIndex, this.chats.length - 1);
|
||||
this.selectChat(this.chats[newIndex]?.id);
|
||||
if (!this.selectedChatID) this.chatListOpen = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting conversation:', err);
|
||||
this.error = 'Failed to delete conversation';
|
||||
}
|
||||
},
|
||||
|
||||
async sendMessage() {
|
||||
const message = this.inputMessage.trim();
|
||||
if (!message || this.loading) return;
|
||||
|
||||
// Update State
|
||||
this.inputMessage = '';
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
// Save Model
|
||||
if (this.selectedModel) localStorage.setItem(MODEL_KEY, this.selectedModel);
|
||||
|
||||
// New Chat
|
||||
if (!this.selectedChatID) {
|
||||
this.chats.unshift({
|
||||
id: IN_PROGRESS_UUID,
|
||||
created_at: new Date().toISOString(),
|
||||
title: '',
|
||||
initial_message: message,
|
||||
message_count: 0,
|
||||
messages: [],
|
||||
});
|
||||
this.selectedChatID = IN_PROGRESS_UUID;
|
||||
}
|
||||
|
||||
// New User Message
|
||||
let userMessage: Message = {
|
||||
id: IN_PROGRESS_UUID,
|
||||
chat_id: this.selectedChatID,
|
||||
role: 'user',
|
||||
thinking: '',
|
||||
content: message,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Get Chat
|
||||
let currentChat: Chat = this.chats.find(
|
||||
(c) => c.id === this.selectedChatID,
|
||||
)!;
|
||||
|
||||
// Add User Message
|
||||
currentChat.messages.push(userMessage);
|
||||
currentChat.message_count += 1;
|
||||
|
||||
// Assistant Message Placeholder
|
||||
let assistantMessage: Message | undefined;
|
||||
|
||||
try {
|
||||
await sendMessage(
|
||||
this.selectedChatID === IN_PROGRESS_UUID ? '' : this.selectedChatID,
|
||||
{ model: this.selectedModel, prompt: message },
|
||||
(chunk: MessageChunk) => {
|
||||
// Handle Chat
|
||||
if (chunk.chat) {
|
||||
Object.assign(currentChat, {
|
||||
...chunk.chat,
|
||||
messages: currentChat.messages,
|
||||
});
|
||||
this.selectedChatID = chunk.chat.id;
|
||||
this.updateHash(chunk.chat.id);
|
||||
}
|
||||
|
||||
// Handle User Message
|
||||
if (chunk.user_message) {
|
||||
Object.assign(userMessage, chunk.user_message);
|
||||
}
|
||||
|
||||
// Handle Assistant Message
|
||||
if (chunk.assistant_message) {
|
||||
if (!assistantMessage) {
|
||||
assistantMessage = chunk.assistant_message;
|
||||
currentChat.messages.push(assistantMessage);
|
||||
} else {
|
||||
const index = currentChat.messages.findIndex(
|
||||
(m) => m.id === assistantMessage!.id,
|
||||
);
|
||||
if (index !== -1) {
|
||||
currentChat.messages[index] = {
|
||||
...assistantMessage,
|
||||
...chunk.assistant_message,
|
||||
};
|
||||
currentChat.messages = [...currentChat.messages];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error sending message:', err);
|
||||
this.error = parseError(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
updateHash(chatID: string | null) {
|
||||
const newRoute = CHAT_ROUTE + (chatID ? '/' + chatID : '');
|
||||
window.history.pushState(null, '', newRoute);
|
||||
},
|
||||
|
||||
async selectChat(chatID: string | null) {
|
||||
this.updateHash(chatID);
|
||||
|
||||
// Load Messages
|
||||
this.selectedChatID = chatID;
|
||||
if (!this.selectedChatID) this.chatListOpen = false;
|
||||
else this.loadChatMessages();
|
||||
},
|
||||
|
||||
async loadChatMessages() {
|
||||
if (!this.selectedChatID) return;
|
||||
|
||||
try {
|
||||
const response = await getChatMessages(this.selectedChatID);
|
||||
const chatIndex = this.chats.findIndex(
|
||||
(c) => c.id == this.selectedChatID,
|
||||
);
|
||||
|
||||
this.chats[chatIndex].messages = response.messages || [];
|
||||
} catch (err) {
|
||||
console.error('Error loading chat messages:', err);
|
||||
this.error = 'Failed to load messages';
|
||||
}
|
||||
},
|
||||
|
||||
get models(): Model[] {
|
||||
if (!this.settings.text_generation_selector) return this._models;
|
||||
return applyFilter(this._models, this.settings.text_generation_selector);
|
||||
},
|
||||
|
||||
get currentChatMessages(): Message[] {
|
||||
if (!this.selectedChatID) return [];
|
||||
const currentChat =
|
||||
this.chats.find((c) => c.id === this.selectedChatID) ?? null;
|
||||
if (!currentChat) return [];
|
||||
return [...currentChat.messages].reverse();
|
||||
},
|
||||
|
||||
renderMarkdown(content: string) {
|
||||
return marked.parse(content);
|
||||
},
|
||||
}));
|
||||
|
||||
function parseError(err: unknown): string {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (msg.includes('401'))
|
||||
return 'Authentication failed. Please check your API settings.';
|
||||
if (msg.includes('404'))
|
||||
return 'API endpoint not found. Please check your configuration.';
|
||||
if (msg.includes('500'))
|
||||
return 'Server error. The text generation service is unavailable.';
|
||||
if (msg.includes('network') || msg.includes('failed to fetch'))
|
||||
return 'Network error. Please check your internet connection.';
|
||||
|
||||
return msg || 'Failed to send message';
|
||||
}
|
||||
396
frontend/src/components/imageManager.ts
Normal file
396
frontend/src/components/imageManager.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import {
|
||||
deleteImage,
|
||||
generateImage,
|
||||
getGeneratedImages,
|
||||
getModels,
|
||||
getSettings,
|
||||
} from '../client';
|
||||
import { ImageRecord } from '../types';
|
||||
import { applyFilter } from '../utils';
|
||||
|
||||
// Constants
|
||||
const STORAGE_KEYS = {
|
||||
MODEL: 'aethera-model',
|
||||
N: 'aethera-n',
|
||||
SEED: 'aethera-seed',
|
||||
SIZE: 'aethera-size',
|
||||
};
|
||||
|
||||
// Types
|
||||
interface StoredSettings {
|
||||
model: string | null;
|
||||
n: string | null;
|
||||
seed: string | null;
|
||||
size: string | null;
|
||||
}
|
||||
|
||||
// Utilities
|
||||
const fileToDataURL = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target?.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Storage Manager
|
||||
const storageManager = {
|
||||
load(): StoredSettings {
|
||||
return {
|
||||
model: localStorage.getItem(STORAGE_KEYS.MODEL),
|
||||
n: localStorage.getItem(STORAGE_KEYS.N),
|
||||
seed: localStorage.getItem(STORAGE_KEYS.SEED),
|
||||
size: localStorage.getItem(STORAGE_KEYS.SIZE),
|
||||
};
|
||||
},
|
||||
|
||||
save({
|
||||
model,
|
||||
n,
|
||||
seed,
|
||||
size,
|
||||
}: {
|
||||
model: string;
|
||||
n: number;
|
||||
seed: number;
|
||||
size: string;
|
||||
}) {
|
||||
localStorage.setItem(STORAGE_KEYS.MODEL, model);
|
||||
localStorage.setItem(STORAGE_KEYS.N, n.toString());
|
||||
localStorage.setItem(STORAGE_KEYS.SEED, seed.toString());
|
||||
localStorage.setItem(STORAGE_KEYS.SIZE, size);
|
||||
},
|
||||
};
|
||||
|
||||
// Canvas Manager
|
||||
const canvasManager = (canvasId: string) => {
|
||||
let canvas: HTMLCanvasElement | null = null;
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
const getCanvas = () => {
|
||||
if (!canvas)
|
||||
canvas = document.getElementById(canvasId) as HTMLCanvasElement;
|
||||
return canvas;
|
||||
};
|
||||
|
||||
const getContext = () => {
|
||||
if (!ctx) ctx = getCanvas()?.getContext('2d');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const clearContext = () => {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
context.fillStyle = 'rgba(0, 0, 0, 0)';
|
||||
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
|
||||
};
|
||||
|
||||
return {
|
||||
getCanvas,
|
||||
getContext,
|
||||
|
||||
clear() {
|
||||
const context = getContext();
|
||||
if (!context) return;
|
||||
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
|
||||
},
|
||||
|
||||
reset() {
|
||||
clearContext();
|
||||
},
|
||||
|
||||
toDataURL(format = 'image/png') {
|
||||
return getCanvas()?.toDataURL(format);
|
||||
},
|
||||
|
||||
async resizeToImage(imageUrl: string) {
|
||||
const cnv = getCanvas();
|
||||
if (!cnv) return { width: 0, height: 0 };
|
||||
|
||||
const img = new Image();
|
||||
return new Promise<{ width: number; height: number }>((resolve) => {
|
||||
img.onload = () => {
|
||||
cnv.width = img.width;
|
||||
cnv.height = img.height;
|
||||
resolve({ width: img.width, height: img.height });
|
||||
};
|
||||
img.src = imageUrl;
|
||||
});
|
||||
},
|
||||
|
||||
initDrawing(lineWidthGetter: () => number) {
|
||||
const cnv = getCanvas();
|
||||
const context = getContext();
|
||||
if (!cnv || !context) return;
|
||||
|
||||
let isDrawing = false;
|
||||
clearContext();
|
||||
|
||||
const getCoords = (e: MouseEvent | TouchEvent) => {
|
||||
const rect = cnv.getBoundingClientRect();
|
||||
const scaleX = cnv.width / rect.width;
|
||||
const scaleY = cnv.height / rect.height;
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
};
|
||||
};
|
||||
|
||||
const startDrawing = (e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault();
|
||||
isDrawing = true;
|
||||
const { x, y } = getCoords(e);
|
||||
context.beginPath();
|
||||
context.moveTo(x, y);
|
||||
};
|
||||
|
||||
const draw = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDrawing) return;
|
||||
e.preventDefault();
|
||||
const { x, y, scaleX, scaleY } = getCoords(e);
|
||||
|
||||
context.lineWidth = lineWidthGetter() * Math.max(scaleX, scaleY);
|
||||
context.lineCap = 'round';
|
||||
context.strokeStyle = 'black';
|
||||
context.lineTo(x, y);
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.moveTo(x, y);
|
||||
};
|
||||
|
||||
const stopDrawing = () => {
|
||||
isDrawing = false;
|
||||
context.beginPath();
|
||||
};
|
||||
|
||||
cnv.addEventListener('mousedown', startDrawing as EventListener);
|
||||
cnv.addEventListener('mousemove', draw as EventListener);
|
||||
cnv.addEventListener('mouseup', stopDrawing);
|
||||
cnv.addEventListener('mouseout', stopDrawing);
|
||||
cnv.addEventListener('touchstart', startDrawing as EventListener);
|
||||
cnv.addEventListener('touchmove', draw as EventListener);
|
||||
cnv.addEventListener('touchend', stopDrawing);
|
||||
cnv.addEventListener('touchcancel', stopDrawing);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Main Component
|
||||
Alpine.data('imageGenerator', () => {
|
||||
const canvas = canvasManager('mask');
|
||||
|
||||
return {
|
||||
// Generation State
|
||||
prompt: '',
|
||||
n: 1,
|
||||
seed: -1,
|
||||
selectedModel: '',
|
||||
size: 'auto',
|
||||
|
||||
// Editing State
|
||||
editingImage: null as { url: string; name: string } | null,
|
||||
editMode: false,
|
||||
isLandscape: false,
|
||||
lineWidth: 20,
|
||||
|
||||
// Object State
|
||||
generatedImages: [] as ImageRecord[],
|
||||
_settings: {} as Record<string, unknown>,
|
||||
_models: [] as unknown[],
|
||||
|
||||
// API State
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
// Lightbox State
|
||||
lightbox: {
|
||||
open: false,
|
||||
imageSrc: '',
|
||||
currentIndex: 0,
|
||||
touchStartX: 0,
|
||||
touchEndX: 0,
|
||||
},
|
||||
|
||||
async init() {
|
||||
[this._models, this._settings, this.generatedImages] = await Promise.all([
|
||||
getModels(),
|
||||
getSettings(),
|
||||
getGeneratedImages(),
|
||||
]);
|
||||
|
||||
this.loadStoredSettings();
|
||||
canvas.initDrawing(() => this.lineWidth);
|
||||
},
|
||||
|
||||
get models() {
|
||||
return applyFilter(
|
||||
this._models,
|
||||
this._settings.image_generation_selector as string,
|
||||
);
|
||||
},
|
||||
|
||||
loadStoredSettings() {
|
||||
const saved = storageManager.load();
|
||||
if (saved.model) this.selectedModel = saved.model;
|
||||
if (saved.n) this.n = parseInt(saved.n);
|
||||
if (saved.seed) this.seed = parseInt(saved.seed);
|
||||
if (saved.size) this.size = saved.size;
|
||||
},
|
||||
|
||||
saveSettings() {
|
||||
storageManager.save({
|
||||
model: this.selectedModel,
|
||||
n: this.n,
|
||||
seed: this.seed,
|
||||
size: this.size,
|
||||
});
|
||||
},
|
||||
|
||||
async buildRequestData() {
|
||||
const requestData: any = {
|
||||
prompt: this.prompt,
|
||||
n: parseInt(this.n.toString()),
|
||||
seed: parseInt(this.seed.toString()),
|
||||
size: this.size || 'auto',
|
||||
model: this.selectedModel,
|
||||
};
|
||||
|
||||
if (this.editMode) {
|
||||
const imageUploader = document.querySelector(
|
||||
'#image-upload',
|
||||
) as HTMLInputElement;
|
||||
requestData.mask = canvas.toDataURL();
|
||||
requestData.image = await fileToDataURL(imageUploader.files![0]);
|
||||
}
|
||||
|
||||
return requestData;
|
||||
},
|
||||
|
||||
async generateImage() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.saveSettings();
|
||||
|
||||
try {
|
||||
const requestData = await this.buildRequestData();
|
||||
const data = await generateImage(requestData);
|
||||
this.generatedImages.unshift(...data);
|
||||
} catch (err: any) {
|
||||
this.error = err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteImage(filename: string) {
|
||||
try {
|
||||
await deleteImage(filename);
|
||||
this.generatedImages = this.generatedImages.filter(
|
||||
(img: ImageRecord) => img.name !== filename,
|
||||
);
|
||||
} catch (err: any) {
|
||||
this.error = err;
|
||||
}
|
||||
},
|
||||
|
||||
async startEdit(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.match('image/*')) {
|
||||
this.error = 'Please select a valid image file';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
this.editMode = true;
|
||||
this.editingImage = { url: imageUrl, name: file.name };
|
||||
|
||||
canvas.reset();
|
||||
|
||||
document
|
||||
.getElementById('edit-panel')
|
||||
?.scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
const dimensions = await canvas.resizeToImage(imageUrl);
|
||||
this.isLandscape = dimensions.width > dimensions.height;
|
||||
} catch (err) {
|
||||
console.error('Error starting image edit:', err);
|
||||
this.error = 'Failed to start editing uploaded image';
|
||||
}
|
||||
},
|
||||
|
||||
cancelEdit() {
|
||||
this.editMode = false;
|
||||
this.editingImage = null;
|
||||
const fileInput = document.getElementById(
|
||||
'image-upload',
|
||||
) as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
},
|
||||
|
||||
clearMask() {
|
||||
canvas.clear();
|
||||
},
|
||||
|
||||
openLightbox(imageSrc: string) {
|
||||
this.lightbox.currentIndex = this.generatedImages.findIndex(
|
||||
(img: ImageRecord) => img.path === imageSrc,
|
||||
);
|
||||
this.lightbox.imageSrc = imageSrc;
|
||||
this.lightbox.open = true;
|
||||
document.body.style.overflow = 'hidden';
|
||||
},
|
||||
|
||||
closeLightbox() {
|
||||
this.lightbox.open = false;
|
||||
document.body.style.overflow = '';
|
||||
},
|
||||
|
||||
nextImage() {
|
||||
if (this.lightbox.currentIndex < this.generatedImages.length - 1) {
|
||||
this.lightbox.currentIndex++;
|
||||
this.lightbox.imageSrc =
|
||||
this.generatedImages[this.lightbox.currentIndex].path;
|
||||
}
|
||||
},
|
||||
|
||||
prevImage() {
|
||||
if (this.lightbox.currentIndex > 0) {
|
||||
this.lightbox.currentIndex--;
|
||||
this.lightbox.imageSrc =
|
||||
this.generatedImages[this.lightbox.currentIndex].path;
|
||||
}
|
||||
},
|
||||
|
||||
handleTouchStart(e: TouchEvent) {
|
||||
this.lightbox.touchStartX = e.changedTouches[0].screenX;
|
||||
},
|
||||
|
||||
handleTouchEnd(e: TouchEvent) {
|
||||
this.lightbox.touchEndX = e.changedTouches[0].screenX;
|
||||
this.handleSwipe();
|
||||
},
|
||||
|
||||
handleSwipe() {
|
||||
const swipeThreshold = 50;
|
||||
const diff = this.lightbox.touchStartX - this.lightbox.touchEndX;
|
||||
|
||||
if (Math.abs(diff) > swipeThreshold) {
|
||||
if (diff > 0) {
|
||||
this.nextImage();
|
||||
} else {
|
||||
this.prevImage();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
48
frontend/src/components/navigationManager.ts
Normal file
48
frontend/src/components/navigationManager.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Alpine: typeof Alpine;
|
||||
}
|
||||
}
|
||||
|
||||
interface NavigationStore {
|
||||
activeTab: string;
|
||||
|
||||
init(): void;
|
||||
loadPage(): Promise<void>;
|
||||
}
|
||||
|
||||
const navigationStore: NavigationStore = {
|
||||
activeTab: '',
|
||||
|
||||
async init() {
|
||||
await this.loadPage();
|
||||
window.addEventListener('hashchange', () => this.loadPage());
|
||||
},
|
||||
|
||||
async loadPage() {
|
||||
const tab = window.location.hash.split('/')[1] || 'chats';
|
||||
if (this.activeTab === tab) return;
|
||||
this.activeTab = tab;
|
||||
|
||||
const pageContent = document.getElementById('page-content');
|
||||
if (!pageContent) throw new Error('Failed to find #page-content');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/pages/${tab}.html`);
|
||||
if (!response.ok) throw new Error('Failed to load page');
|
||||
|
||||
pageContent.innerHTML = await response.text();
|
||||
} catch (error) {
|
||||
console.error('Page load error:', error);
|
||||
pageContent.innerHTML = `
|
||||
<div class="bg-tertiary-50 border border-tertiary-200 rounded-lg p-4">
|
||||
<p class="text-tertiary-700">Failed to load page. Please try again.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Alpine.store('navigation', navigationStore);
|
||||
29
frontend/src/components/settingsManager.ts
Normal file
29
frontend/src/components/settingsManager.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import { getSettings, saveSettings } from '../client';
|
||||
import { Settings } from '../types';
|
||||
|
||||
Alpine.data('settingsManager', () => ({
|
||||
settings: {} as Settings,
|
||||
loading: false,
|
||||
saved: false,
|
||||
error: '',
|
||||
|
||||
async init() {
|
||||
this.settings = await getSettings();
|
||||
},
|
||||
|
||||
async saveSettings() {
|
||||
this.loading = true;
|
||||
this.saved = false;
|
||||
this.error = '';
|
||||
|
||||
try {
|
||||
await saveSettings(this.settings);
|
||||
this.saved = true;
|
||||
} catch (err) {
|
||||
this.error = String(err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
49
frontend/src/components/themeManager.ts
Normal file
49
frontend/src/components/themeManager.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import Alpine from 'alpinejs';
|
||||
import {
|
||||
type ThemeMode,
|
||||
loadTheme,
|
||||
saveThemeMode,
|
||||
applyTheme,
|
||||
getNextThemeMode,
|
||||
} from '../theme';
|
||||
|
||||
interface ThemeStore {
|
||||
mode: ThemeMode;
|
||||
|
||||
init(): void;
|
||||
cycleTheme(): void;
|
||||
getThemeIcon(): string;
|
||||
}
|
||||
|
||||
const themeStore: ThemeStore = {
|
||||
mode: 'system',
|
||||
|
||||
init() {
|
||||
const { mode } = loadTheme();
|
||||
this.mode = mode;
|
||||
applyTheme(mode);
|
||||
|
||||
// System Theme Changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', () => {
|
||||
if (this.mode === 'system') {
|
||||
applyTheme('system');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
cycleTheme() {
|
||||
const nextMode = getNextThemeMode(this.mode);
|
||||
this.mode = nextMode;
|
||||
saveThemeMode(nextMode);
|
||||
applyTheme(nextMode);
|
||||
},
|
||||
|
||||
getThemeIcon() {
|
||||
if (this.mode === 'dark') return 'moon';
|
||||
if (this.mode === 'light') return 'sun';
|
||||
return 'system';
|
||||
},
|
||||
};
|
||||
|
||||
Alpine.store('theme', themeStore);
|
||||
Reference in New Issue
Block a user