Asked claude opus to make an audit of code and use coding guidelines etc. This is the fix
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { getSystemInfo, reboot, getOTAStatus, getUpcomingTasks } from "./lib/api.js";
|
||||
import { formatUptime, formatBytes, formatRelativeDate, isOverdue } from "./lib/utils.js";
|
||||
import OTAUpdate from "./lib/OTAUpdate.svelte";
|
||||
import Sidebar from "./lib/Sidebar.svelte";
|
||||
import TaskManager from "./lib/TaskManager.svelte";
|
||||
@@ -32,49 +33,7 @@
|
||||
|
||||
let upcomingData = $state({ users: [] });
|
||||
|
||||
/** Format uptime seconds into human-readable string */
|
||||
function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
const parts = [];
|
||||
if (d > 0) parts.push(`${d}d`);
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
parts.push(`${s}s`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** Format bytes into human-readable string */
|
||||
function formatBytes(bytes) {
|
||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function formatRelativeDate(timestamp) {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = timestamp - now;
|
||||
const absDiff = Math.abs(diff);
|
||||
|
||||
if (absDiff < 3600) {
|
||||
const mins = Math.round(absDiff / 60);
|
||||
return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
|
||||
}
|
||||
if (absDiff < 86400) {
|
||||
const hours = Math.round(absDiff / 3600);
|
||||
return diff < 0 ? `${hours}h ago` : `in ${hours}h`;
|
||||
}
|
||||
const days = Math.round(absDiff / 86400);
|
||||
return diff < 0 ? `${days}d ago` : `in ${days}d`;
|
||||
}
|
||||
|
||||
function isOverdue(timestamp) {
|
||||
return timestamp < Date.now() / 1000;
|
||||
}
|
||||
|
||||
let isFetching = false;
|
||||
let isFetching = false; // mutex, not reactive
|
||||
let lastKnownFirmware = null;
|
||||
let lastKnownSlot = null;
|
||||
async function fetchAll(silent = false) {
|
||||
@@ -158,7 +117,7 @@
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="app-layout">
|
||||
<div class="flex min-h-screen bg-bg-primary">
|
||||
<Sidebar
|
||||
{currentView}
|
||||
isOpen={mobileMenuOpen}
|
||||
@@ -168,25 +127,25 @@
|
||||
|
||||
{#if mobileMenuOpen}
|
||||
<button
|
||||
class="mobile-backdrop"
|
||||
class="fixed inset-0 bg-black/40 backdrop-blur z-[999] border-none p-0 cursor-pointer"
|
||||
onclick={() => mobileMenuOpen = false}
|
||||
aria-label="Close menu"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<main class="main-content">
|
||||
<main class="main-gradient-bg flex-1 p-8 h-screen overflow-y-auto max-md:p-4 max-md:h-auto">
|
||||
<!-- Mobile Top Bar -->
|
||||
<header class="mobile-header">
|
||||
<header class="hidden max-md:flex items-center justify-between px-4 py-3 bg-bg-card border-b border-border sticky top-0 z-50 backdrop-blur-[10px] max-md:-mx-4 max-md:-mt-4 max-md:mb-5">
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
<button class="hamburger" onclick={() => mobileMenuOpen = true}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<button class="bg-transparent border-none text-text-primary p-2 cursor-pointer" onclick={() => mobileMenuOpen = true}>
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="mobile-title">Calendink</span>
|
||||
<div style="width: 40px;"></div> <!-- Spacer -->
|
||||
<span class="font-bold text-base tracking-[-0.02em] text-accent">Calendink</span>
|
||||
<div class="w-10"></div> <!-- Spacer -->
|
||||
</header>
|
||||
|
||||
<div class="w-full max-w-6xl mx-auto space-y-8">
|
||||
@@ -378,7 +337,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Updates & Maintenance Card -->
|
||||
<OTAUpdate onReboot={() => { status = "rebooting"; isRecovering = true; }} />
|
||||
<OTAUpdate otaInfo={otaStatus} {systemInfo} onReboot={() => { status = "rebooting"; isRecovering = true; }} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -426,87 +385,3 @@
|
||||
</div>
|
||||
|
||||
<Spinner />
|
||||
|
||||
<style>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: radial-gradient(circle at top right, var(--color-bg-card), transparent 40%),
|
||||
var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-bg-card);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-primary);
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hamburger svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.mobile-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 999;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 16px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
display: flex;
|
||||
margin: -16px -16px 20px -16px;
|
||||
}
|
||||
|
||||
.mobile-backdrop {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,4 +16,29 @@
|
||||
--color-success: #22c55e;
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-hover: #f87171;
|
||||
}
|
||||
|
||||
/*
|
||||
* Global utility: main content radial gradient background.
|
||||
* Can't be expressed as a Tailwind arbitrary value due to multiple background layers.
|
||||
*/
|
||||
.main-gradient-bg {
|
||||
background: radial-gradient(circle at top right, var(--color-bg-card), transparent 40%),
|
||||
var(--color-bg-primary);
|
||||
}
|
||||
|
||||
/*
|
||||
* Global utility: makes the native date picker icon cover the full input area.
|
||||
* This is a vendor pseudo-element — cannot be done with Tailwind utilities.
|
||||
*/
|
||||
.date-input::-webkit-calendar-picker-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,47 +1,26 @@
|
||||
<script>
|
||||
let { onReboot = null } = $props();
|
||||
import { getOTAStatus, uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle, getSystemInfo } from "./api.js";
|
||||
|
||||
import { uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle } from "./api.js";
|
||||
|
||||
// otaInfo and systemInfo are passed from App.svelte (already fetched there every 5s)
|
||||
let {
|
||||
onReboot = null,
|
||||
otaInfo = { active_slot: -1, active_partition: "—", target_partition: "—", partitions: [], running_firmware_label: "—" },
|
||||
systemInfo = { firmware: "—" }
|
||||
} = $props();
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
|
||||
/** @type {'idle' | 'loading_status' | 'uploading' | 'success' | 'error'} */
|
||||
/** @type {'idle' | 'uploading' | 'success' | 'error'} */
|
||||
let status = $state("idle");
|
||||
let errorMsg = $state("");
|
||||
let uploadProgress = $state(0);
|
||||
|
||||
let otaInfo = $state({
|
||||
active_slot: -1,
|
||||
active_partition: "—",
|
||||
target_partition: "—",
|
||||
partitions: [],
|
||||
running_firmware_label: "—"
|
||||
});
|
||||
|
||||
let systemInfo = $state({
|
||||
firmware: "—"
|
||||
});
|
||||
|
||||
let selectedFile = $state(null);
|
||||
let showAdvanced = $state(false);
|
||||
/** @type {'frontend' | 'firmware' | 'bundle'} */
|
||||
let updateMode = $state("frontend");
|
||||
let isDragging = $state(false);
|
||||
|
||||
async function fetchStatus() {
|
||||
status = "loading_status";
|
||||
try {
|
||||
[otaInfo, systemInfo] = await Promise.all([getOTAStatus(), getSystemInfo()]);
|
||||
status = "idle";
|
||||
} catch (e) {
|
||||
status = "error";
|
||||
errorMsg = "Failed to fetch OTA status: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetchStatus();
|
||||
});
|
||||
|
||||
function handleFileChange(event) {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) processFile(files[0]);
|
||||
@@ -114,7 +93,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
const currentTarget = $derived(() => {
|
||||
const currentTarget = $derived.by(() => {
|
||||
if (updateMode === 'bundle') return 'FW + UI';
|
||||
if (updateMode === 'frontend') return otaInfo.target_partition;
|
||||
// For firmware, target is the slot that is NOT the running one
|
||||
@@ -182,7 +161,7 @@
|
||||
OTA Upgrade ({updateMode === 'frontend' ? 'UI' : 'Firmware'})
|
||||
</h3>
|
||||
<div class="text-[10px] text-text-secondary">
|
||||
Target: <span class="font-mono text-accent">{currentTarget()}</span>
|
||||
Target: <span class="font-mono text-accent">{currentTarget}</span>
|
||||
{#if updateMode === 'frontend' && otaInfo.partitions}
|
||||
<span class="ml-1">
|
||||
({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(1)} MB)
|
||||
|
||||
@@ -16,17 +16,29 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<aside class="sidebar {collapsed ? 'collapsed' : ''} {isOpen ? 'mobile-open' : ''}">
|
||||
<div class="sidebar-header">
|
||||
<aside class="
|
||||
flex flex-col bg-bg-card border-r border-border h-screen flex-shrink-0 sticky top-0
|
||||
transition-all duration-300
|
||||
{collapsed ? 'w-16' : 'w-[200px]'}
|
||||
max-md:fixed max-md:left-0 max-md:top-0 max-md:z-[1000] max-md:!w-[280px]
|
||||
max-md:shadow-[20px_0_50px_rgba(0,0,0,0.3)]
|
||||
{isOpen ? 'max-md:flex' : 'max-md:hidden'}
|
||||
">
|
||||
<div class="flex items-center border-b border-border min-h-14 px-3 py-4
|
||||
{collapsed ? 'justify-center' : 'justify-between'}
|
||||
max-md:px-4 max-md:py-5">
|
||||
{#if !collapsed}
|
||||
<span class="sidebar-title">Menu</span>
|
||||
<span class="text-xs font-bold uppercase tracking-[0.05em] text-text-secondary whitespace-nowrap overflow-hidden">
|
||||
Menu
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
class="collapse-btn {onToggle ? 'mobile-only' : ''}"
|
||||
class="bg-transparent border-none text-text-secondary cursor-pointer p-1.5 flex items-center justify-center rounded-md transition-all flex-shrink-0 hover:text-text-primary hover:bg-bg-card-hover
|
||||
{onToggle ? 'max-md:flex' : ''}"
|
||||
onclick={() => onToggle ? onToggle() : (collapsed = !collapsed)}
|
||||
title={collapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<svg class="w-[18px] h-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
@@ -34,160 +46,22 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<nav class="flex flex-col gap-1 p-2">
|
||||
{#each navItems as item}
|
||||
<button
|
||||
class="nav-item {currentView === item.id ? 'active' : ''}"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 border-none rounded-lg cursor-pointer text-[13px] font-medium transition-all whitespace-nowrap text-left
|
||||
max-md:px-4 max-md:py-3.5 max-md:text-[15px]
|
||||
{currentView === item.id
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'bg-transparent text-text-secondary hover:bg-bg-card-hover hover:text-text-primary'}"
|
||||
onclick={() => onNavigate(item.id)}
|
||||
title={item.label}
|
||||
>
|
||||
<span class="nav-icon">{item.icon}</span>
|
||||
<span class="text-base flex-shrink-0">{item.icon}</span>
|
||||
{#if !collapsed}
|
||||
<span class="nav-label">{item.label}</span>
|
||||
<span class="overflow-hidden">{item.label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
height: 100vh;
|
||||
background: var(--color-bg-card);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
display: none; /* Hide by default on mobile */
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
width: 280px !important;
|
||||
height: 100vh;
|
||||
transform: translateX(-100%);
|
||||
box-shadow: 20px 0 50px rgba(0,0,0,0.3);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sidebar.mobile-open {
|
||||
display: flex;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 14px 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
<script>
|
||||
import { pendingRequests } from './stores.js';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
let showSpinner = false;
|
||||
let timer;
|
||||
|
||||
// Subscribe to the store
|
||||
const unsubscribe = pendingRequests.subscribe(count => {
|
||||
|
||||
let showSpinner = $state(false);
|
||||
let timer = null; // script-only handle, not reactive
|
||||
|
||||
// Track pending request count and show spinner after 1s delay
|
||||
$effect(() => {
|
||||
const count = $pendingRequests;
|
||||
if (count > 0) {
|
||||
// Only show the spinner if the request takes longer than 1000ms
|
||||
if (!timer) {
|
||||
timer = setTimeout(() => {
|
||||
showSpinner = true;
|
||||
}, 1000);
|
||||
timer = setTimeout(() => { showSpinner = true; }, 1000);
|
||||
}
|
||||
} else {
|
||||
// Instantly hide the spinner when all requests finish
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
@@ -23,27 +19,13 @@
|
||||
showSpinner = false;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe();
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showSpinner}
|
||||
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity duration-300">
|
||||
<div class="flex flex-col items-center p-8 bg-surface-base rounded-2xl shadow-xl border border-divider">
|
||||
<!-- Loading circle animation -->
|
||||
<div class="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<div class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div class="flex flex-col items-center p-8 bg-bg-card rounded-2xl shadow-xl border border-border">
|
||||
<div class="w-12 h-12 border-4 border-accent border-t-transparent rounded-full animate-spin"></div>
|
||||
<p class="mt-4 text-text-primary font-medium tracking-wide animate-pulse">Communicating with Device...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/*
|
||||
* Note: 'bg-surface-base', 'border-divider', 'text-text-primary'
|
||||
* are assumed to be part of the app's global tailwind theme.
|
||||
* Adjust classes if necessary.
|
||||
*/
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { getTasks, addTask, updateTask, deleteTask } from './api.js';
|
||||
import { formatRelativeDate, isOverdue } from './utils.js';
|
||||
import UserManager from './UserManager.svelte';
|
||||
|
||||
let selectedUserId = $state(null);
|
||||
@@ -97,7 +98,6 @@
|
||||
async function handleAddTask(e) {
|
||||
e.preventDefault();
|
||||
if (!newTitle.trim()) return;
|
||||
// For non-recurrent tasks, require a date
|
||||
if (newRecurrence === 0 && !newDueDay) return;
|
||||
|
||||
const dueTimestamp = newRecurrence > 0
|
||||
@@ -183,59 +183,48 @@
|
||||
newDueDay = `${year}-${month}-${dayNum}`;
|
||||
}
|
||||
|
||||
function formatRelativeDate(timestamp) {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = timestamp - now;
|
||||
const absDiff = Math.abs(diff);
|
||||
|
||||
if (absDiff < 3600) {
|
||||
const mins = Math.round(absDiff / 60);
|
||||
return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
|
||||
}
|
||||
if (absDiff < 86400) {
|
||||
const hours = Math.round(absDiff / 3600);
|
||||
return diff < 0 ? `${hours}h ago` : `in ${hours}h`;
|
||||
}
|
||||
const days = Math.round(absDiff / 86400);
|
||||
return diff < 0 ? `${days}d ago` : `in ${days}d`;
|
||||
}
|
||||
|
||||
function isOverdue(task) {
|
||||
if (task.recurrence > 0) return false; // Recurrent tasks don't have overdue
|
||||
return task.due_date < Date.now() / 1000;
|
||||
function taskIsOverdue(task) {
|
||||
if (task.recurrence > 0) return false;
|
||||
return isOverdue(task.due_date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="task-manager">
|
||||
<div class="w-full">
|
||||
<UserManager bind:selectedUserId onUsersChanged={fetchTasks} />
|
||||
|
||||
{#if selectedUserId}
|
||||
<div class="task-header">
|
||||
<h2 class="task-title">Tasks</h2>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-[0.05em] text-text-primary">Tasks</h2>
|
||||
{#if !showAddForm}
|
||||
<button class="add-task-btn" onclick={() => { showAddForm = true; setTodayForNewTask(); }}>+ Add Task</button>
|
||||
<button
|
||||
class="px-3.5 py-1.5 rounded-lg border border-accent bg-accent/10 text-accent cursor-pointer text-xs font-semibold transition-all hover:bg-accent/20"
|
||||
onclick={() => { showAddForm = true; setTodayForNewTask(); }}
|
||||
>+ Add Task</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showAddForm}
|
||||
<form class="add-task-form" onsubmit={handleAddTask}>
|
||||
<form class="flex flex-col gap-2 p-3 rounded-[10px] border border-border bg-bg-card mb-3" onsubmit={handleAddTask}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newTitle}
|
||||
placeholder="Task title..."
|
||||
class="task-input"
|
||||
class="px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none w-full box-border focus:border-accent"
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Period</span>
|
||||
<div class="period-selector">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Period</span>
|
||||
<div class="flex gap-1">
|
||||
{#each PERIODS as p, i}
|
||||
<button
|
||||
type="button"
|
||||
class="period-btn {hasBit(newPeriod, i) ? 'active' : ''}"
|
||||
class="flex-1 px-2 py-1.5 rounded-md border cursor-pointer text-[11px] transition-all whitespace-nowrap
|
||||
{hasBit(newPeriod, i)
|
||||
? 'border-accent bg-accent/15 text-accent font-semibold'
|
||||
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
|
||||
onclick={() => newPeriod = toggleBit(newPeriod, i)}
|
||||
>{PERIOD_ICONS[i]} {p}</button>
|
||||
{/each}
|
||||
@@ -243,14 +232,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Recurrence</span>
|
||||
<div class="recurrence-selector">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Recurrence</span>
|
||||
<div class="flex gap-[3px] flex-wrap">
|
||||
{#each DAYS as day, i}
|
||||
<button
|
||||
type="button"
|
||||
class="rec-btn {hasBit(newRecurrence, i) ? 'active' : ''}"
|
||||
class="px-2 py-[5px] rounded-md border cursor-pointer text-[11px] transition-all min-w-8 text-center
|
||||
{hasBit(newRecurrence, i)
|
||||
? 'border-accent bg-accent/15 text-accent font-semibold'
|
||||
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
|
||||
onclick={() => newRecurrence = toggleBit(newRecurrence, i)}
|
||||
>{day}</button>
|
||||
{/each}
|
||||
@@ -259,36 +251,46 @@
|
||||
</div>
|
||||
|
||||
{#if newRecurrence === 0}
|
||||
<div class="field-row">
|
||||
<label class="field-group">
|
||||
<span class="field-label">Date</span>
|
||||
<input type="date" bind:value={newDueDay} class="task-date-input" />
|
||||
<div class="flex gap-2">
|
||||
<label class="flex flex-col gap-1 flex-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Date</span>
|
||||
<input type="date" bind:value={newDueDay}
|
||||
class="date-input px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none [color-scheme:dark] flex-1 cursor-pointer relative focus:border-accent" />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" disabled={!newTitle.trim() || (newRecurrence === 0 && !newDueDay)}>Add</button>
|
||||
<button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newTitle = ''; newDueDay = ''; newRecurrence = 0; newPeriod = 0x01; }}>Cancel</button>
|
||||
<div class="flex gap-1.5">
|
||||
<button type="submit"
|
||||
class="px-4 py-1.5 rounded-lg border-none bg-accent text-white cursor-pointer text-xs font-semibold transition-[filter] hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={!newTitle.trim() || (newRecurrence === 0 && !newDueDay)}>Add</button>
|
||||
<button type="button"
|
||||
class="px-4 py-1.5 rounded-lg border border-border bg-transparent text-text-secondary cursor-pointer text-xs transition-all hover:bg-bg-card-hover"
|
||||
onclick={() => { showAddForm = false; newTitle = ''; newDueDay = ''; newRecurrence = 0; newPeriod = 0x01; }}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="task-list">
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each tasks as task}
|
||||
<div class="task-item {task.completed ? 'completed' : ''} {isOverdue(task) && !task.completed ? 'overdue' : ''}">
|
||||
<div class="flex items-center justify-between flex-wrap px-3.5 py-2.5 rounded-[10px] border bg-bg-card transition-all hover:bg-bg-card-hover
|
||||
{task.completed ? 'opacity-50' : ''}
|
||||
{taskIsOverdue(task) && !task.completed ? 'border-danger/40' : 'border-border'}">
|
||||
{#if editingTaskId === task.id}
|
||||
<form class="edit-form" onsubmit={saveEdit}>
|
||||
<input type="text" bind:value={editTitle} class="task-input" />
|
||||
<form class="w-full flex flex-col gap-2 p-0" onsubmit={saveEdit}>
|
||||
<input type="text" bind:value={editTitle}
|
||||
class="px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none w-full box-border focus:border-accent" />
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Period</span>
|
||||
<div class="period-selector">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Period</span>
|
||||
<div class="flex gap-1">
|
||||
{#each PERIODS as p, i}
|
||||
<button
|
||||
type="button"
|
||||
class="period-btn {hasBit(editPeriod, i) ? 'active' : ''}"
|
||||
<button type="button"
|
||||
class="flex-1 px-2 py-1.5 rounded-md border cursor-pointer text-[11px] transition-all whitespace-nowrap
|
||||
{hasBit(editPeriod, i)
|
||||
? 'border-accent bg-accent/15 text-accent font-semibold'
|
||||
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
|
||||
onclick={() => editPeriod = toggleBit(editPeriod, i)}
|
||||
>{PERIOD_ICONS[i]} {p}</button>
|
||||
{/each}
|
||||
@@ -296,14 +298,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<span class="field-label">Recurrence</span>
|
||||
<div class="recurrence-selector">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col gap-1 flex-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Recurrence</span>
|
||||
<div class="flex gap-[3px] flex-wrap">
|
||||
{#each DAYS as day, i}
|
||||
<button
|
||||
type="button"
|
||||
class="rec-btn {hasBit(editRecurrence, i) ? 'active' : ''}"
|
||||
<button type="button"
|
||||
class="px-2 py-[5px] rounded-md border cursor-pointer text-[11px] transition-all min-w-8 text-center
|
||||
{hasBit(editRecurrence, i)
|
||||
? 'border-accent bg-accent/15 text-accent font-semibold'
|
||||
: 'border-border bg-bg-primary text-text-secondary hover:bg-bg-card-hover'}"
|
||||
onclick={() => editRecurrence = toggleBit(editRecurrence, i)}
|
||||
>{day}</button>
|
||||
{/each}
|
||||
@@ -312,425 +316,71 @@
|
||||
</div>
|
||||
|
||||
{#if editRecurrence === 0}
|
||||
<div class="field-row">
|
||||
<label class="field-group">
|
||||
<span class="field-label">Date</span>
|
||||
<input type="date" bind:value={editDueDay} class="task-date-input" />
|
||||
<div class="flex gap-2">
|
||||
<label class="flex flex-col gap-1 flex-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Date</span>
|
||||
<input type="date" bind:value={editDueDay}
|
||||
class="date-input px-3 py-2 rounded-lg border border-border bg-bg-primary text-text-primary text-[13px] outline-none [color-scheme:dark] flex-1 cursor-pointer relative focus:border-accent" />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn-secondary btn-sm" onclick={() => editingTaskId = null}>Cancel</button>
|
||||
<div class="flex gap-1.5">
|
||||
<button type="submit"
|
||||
class="px-2.5 py-1 rounded-lg border-none bg-accent text-white cursor-pointer text-[11px] font-semibold transition-[filter] hover:brightness-110">Save</button>
|
||||
<button type="button"
|
||||
class="px-2.5 py-1 rounded-lg border border-border bg-transparent text-text-secondary cursor-pointer text-[11px] transition-all hover:bg-bg-card-hover"
|
||||
onclick={() => editingTaskId = null}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="task-left">
|
||||
<div class="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.completed}
|
||||
onchange={() => handleToggleComplete(task)}
|
||||
class="task-checkbox"
|
||||
class="w-4 h-4 cursor-pointer flex-shrink-0"
|
||||
style="accent-color: var(--color-accent)"
|
||||
/>
|
||||
<div class="task-info">
|
||||
<span class="task-text">{task.title}</span>
|
||||
<div class="task-meta">
|
||||
<span class="task-period">{periodIcons(task.period)} {formatPeriod(task.period)}</span>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-[13px] text-text-primary whitespace-nowrap overflow-hidden text-ellipsis
|
||||
{task.completed ? 'line-through text-text-secondary' : ''}">{task.title}</span>
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<span class="text-[10px] text-text-secondary font-medium">{periodIcons(task.period)} {formatPeriod(task.period)}</span>
|
||||
{#if task.recurrence > 0}
|
||||
<span class="task-recurrence">🔁 {formatRecurrence(task.recurrence)}</span>
|
||||
<span class="text-[10px] text-accent font-medium">🔁 {formatRecurrence(task.recurrence)}</span>
|
||||
{:else}
|
||||
<span class="task-due {isOverdue(task) && !task.completed ? 'text-overdue' : ''}">
|
||||
<span class="text-[10px] font-medium {taskIsOverdue(task) && !task.completed ? 'text-danger' : 'text-text-secondary'}">
|
||||
{formatRelativeDate(task.due_date)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<button class="action-btn" onclick={() => startEditing(task)} title="Edit">✏️</button>
|
||||
<div class="flex gap-1 items-center flex-shrink-0">
|
||||
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 transition-[background] hover:opacity-100 hover:bg-bg-card-hover"
|
||||
onclick={() => startEditing(task)} title="Edit">✏️</button>
|
||||
{#if confirmDeleteId === task.id}
|
||||
<button class="action-btn text-danger" onclick={() => handleDelete(task.id)} title="Confirm">✓</button>
|
||||
<button class="action-btn" onclick={() => confirmDeleteId = null} title="Cancel">✕</button>
|
||||
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded text-danger opacity-60 hover:opacity-100"
|
||||
onclick={() => handleDelete(task.id)} title="Confirm">✓</button>
|
||||
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 hover:opacity-100 hover:bg-bg-card-hover"
|
||||
onclick={() => confirmDeleteId = null} title="Cancel">✕</button>
|
||||
{:else}
|
||||
<button class="action-btn" onclick={() => confirmDeleteId = task.id} title="Delete">🗑️</button>
|
||||
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded opacity-60 transition-[background] hover:opacity-100 hover:bg-bg-card-hover"
|
||||
onclick={() => confirmDeleteId = task.id} title="Delete">🗑️</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">No tasks yet. Add one above!</div>
|
||||
<div class="text-center p-8 text-text-secondary text-[13px]">No tasks yet. Add one above!</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">Select or add a user to see their tasks.</div>
|
||||
<div class="text-center p-8 text-text-secondary text-[13px]">Select or add a user to see their tasks.</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="task-error">{error}</div>
|
||||
<div class="mt-2 px-2.5 py-1.5 rounded-md bg-danger/10 border border-danger/20 text-danger text-[11px]">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.task-manager {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.add-task-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 10%, transparent);
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.add-task-btn:hover {
|
||||
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.add-task-form, .edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.task-input {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.task-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.period-selector {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.period-btn:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
border-color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recurrence-selector {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rec-btn {
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: all 0.15s;
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rec-btn:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.rec-btn.active {
|
||||
border-color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-date-input {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
color-scheme: dark;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Make the native picker icon cover the entire input (Chrome/Edge only) */
|
||||
.task-date-input::-webkit-calendar-picker-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-date-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.task-item .edit-form {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.task-item.completed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.task-item.overdue {
|
||||
border-color: color-mix(in srgb, var(--color-danger) 40%, transparent);
|
||||
}
|
||||
|
||||
.task-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-text {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.completed .task-text {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-period {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-recurrence {
|
||||
font-size: 10px;
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-due {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.text-overdue {
|
||||
color: var(--color-danger) !important;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task-error {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 20%, transparent);
|
||||
color: var(--color-danger);
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,436 +1,208 @@
|
||||
<script>
|
||||
import { getUsers, addUser, removeUser, updateUser } from './api.js';
|
||||
|
||||
let {
|
||||
selectedUserId = $bindable(null),
|
||||
onUsersChanged = () => {},
|
||||
mode = 'selector' // 'selector' | 'manager'
|
||||
} = $props();
|
||||
let {
|
||||
selectedUserId = $bindable(null),
|
||||
onUsersChanged = () => {},
|
||||
mode = 'selector' // 'selector' | 'manager'
|
||||
} = $props();
|
||||
|
||||
let users = $state([]);
|
||||
let newUserName = $state('');
|
||||
let showAddForm = $state(false);
|
||||
let error = $state('');
|
||||
let confirmDeleteId = $state(null);
|
||||
|
||||
// Edit state
|
||||
let editingUserId = $state(null);
|
||||
let editName = $state('');
|
||||
let users = $state([]);
|
||||
let newUserName = $state('');
|
||||
let showAddForm = $state(false);
|
||||
let error = $state('');
|
||||
let confirmDeleteId = $state(null);
|
||||
|
||||
// Edit state
|
||||
let editingUserId = $state(null);
|
||||
let editName = $state('');
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
users = await getUsers();
|
||||
if (users.length > 0 && !selectedUserId && mode === 'selector') {
|
||||
selectedUserId = users[0].id;
|
||||
}
|
||||
// If selected user was deleted, select first available
|
||||
if (selectedUserId && !users.find(u => u.id === selectedUserId)) {
|
||||
selectedUserId = users.length > 0 ? users[0].id : null;
|
||||
}
|
||||
error = '';
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
users = await getUsers();
|
||||
if (users.length > 0 && !selectedUserId && mode === 'selector') {
|
||||
selectedUserId = users[0].id;
|
||||
}
|
||||
// If selected user was deleted, select first available
|
||||
if (selectedUserId && !users.find(u => u.id === selectedUserId)) {
|
||||
selectedUserId = users.length > 0 ? users[0].id : null;
|
||||
}
|
||||
error = '';
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddUser() {
|
||||
if (!newUserName.trim()) return;
|
||||
try {
|
||||
const user = await addUser(newUserName.trim());
|
||||
newUserName = '';
|
||||
showAddForm = false;
|
||||
await fetchUsers();
|
||||
if (mode === 'selector') selectedUserId = user.id;
|
||||
onUsersChanged();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
async function handleAddUser() {
|
||||
if (!newUserName.trim()) return;
|
||||
try {
|
||||
const user = await addUser(newUserName.trim());
|
||||
newUserName = '';
|
||||
showAddForm = false;
|
||||
await fetchUsers();
|
||||
if (mode === 'selector') selectedUserId = user.id;
|
||||
onUsersChanged();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveUser(id) {
|
||||
try {
|
||||
await removeUser(id);
|
||||
confirmDeleteId = null;
|
||||
await fetchUsers();
|
||||
onUsersChanged();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
async function handleRemoveUser(id) {
|
||||
try {
|
||||
await removeUser(id);
|
||||
confirmDeleteId = null;
|
||||
await fetchUsers();
|
||||
onUsersChanged();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(user) {
|
||||
editingUserId = user.id;
|
||||
editName = user.name;
|
||||
}
|
||||
function startEditing(user) {
|
||||
editingUserId = user.id;
|
||||
editName = user.name;
|
||||
}
|
||||
|
||||
async function handleUpdateUser() {
|
||||
if (!editName.trim()) return;
|
||||
try {
|
||||
await updateUser(editingUserId, editName.trim());
|
||||
editingUserId = null;
|
||||
await fetchUsers();
|
||||
onUsersChanged();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
async function handleUpdateUser() {
|
||||
if (!editName.trim()) return;
|
||||
try {
|
||||
await updateUser(editingUserId, editName.trim());
|
||||
editingUserId = null;
|
||||
await fetchUsers();
|
||||
onUsersChanged();
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetchUsers();
|
||||
});
|
||||
</script>
|
||||
// Load initial data on mount
|
||||
$effect(() => {
|
||||
fetchUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="user-manager mode-{mode}">
|
||||
{#if mode === 'selector'}
|
||||
<div class="user-bar">
|
||||
<div class="user-chips">
|
||||
{#each users as user}
|
||||
<!-- svelte-ignore node_invalid_placement_ssr -->
|
||||
<div
|
||||
class="user-chip {selectedUserId === user.id ? 'selected' : ''}"
|
||||
onclick={() => selectedUserId = user.id}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }}
|
||||
>
|
||||
<span class="user-name">{user.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Manager Mode -->
|
||||
<header class="manager-header">
|
||||
<h1 class="text-xl font-bold text-text-primary">User Management</h1>
|
||||
<button class="add-user-btn-large" onclick={() => showAddForm = true}>
|
||||
+ New User
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if showAddForm}
|
||||
<div class="modal-overlay">
|
||||
<form class="add-user-modal" onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}>
|
||||
<h3 class="text-lg font-semibold">Add User</h3>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newUserName}
|
||||
placeholder="Name..."
|
||||
class="modal-input"
|
||||
autofocus
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newUserName = ''; }}>Cancel</button>
|
||||
<button type="submit" class="btn-primary" disabled={!newUserName.trim()}>Add User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="user-list">
|
||||
{#each users as user}
|
||||
<div class="user-item">
|
||||
{#if editingUserId === user.id}
|
||||
<form class="edit-row" onsubmit={(e) => { e.preventDefault(); handleUpdateUser(); }}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input type="text" bind:value={editName} class="edit-input" autofocus />
|
||||
<div class="edit-actions">
|
||||
<button type="submit" class="save-btn" title="Save">✓</button>
|
||||
<button type="button" class="cancel-btn" onclick={() => editingUserId = null} title="Cancel">✕</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="user-info">
|
||||
<span class="user-name-large">{user.name}</span>
|
||||
<span class="user-id">ID: {user.id}</span>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<button class="action-btn" onclick={() => startEditing(user)} title="Rename">✏️</button>
|
||||
{#if confirmDeleteId === user.id}
|
||||
<button class="action-btn danger" onclick={() => handleRemoveUser(user.id)}>Delete!</button>
|
||||
<button class="action-btn" onclick={() => confirmDeleteId = null}>Cancel</button>
|
||||
{:else}
|
||||
<button class="action-btn" onclick={() => confirmDeleteId = user.id} title="Delete">🗑️</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">No users found. Create one to get started!</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="user-error">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
{#if mode === 'selector'}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap gap-1.5 items-center">
|
||||
{#each users as user}
|
||||
<!-- svelte-ignore node_invalid_placement_ssr -->
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-4 py-1.5 rounded-full border cursor-pointer text-[13px] font-semibold
|
||||
transition-all duration-200 hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)]
|
||||
{selectedUserId === user.id
|
||||
? 'bg-accent/20 border-accent text-accent ring-1 ring-accent'
|
||||
: 'border-border bg-bg-card text-text-secondary hover:border-accent hover:text-text-primary'}"
|
||||
onclick={() => selectedUserId = user.id}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }}
|
||||
>
|
||||
<span>{user.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Manager Mode -->
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-xl font-bold text-text-primary">User Management</h1>
|
||||
<button
|
||||
class="px-5 py-2.5 rounded-xl bg-accent text-white border-none font-semibold cursor-pointer transition-all
|
||||
shadow-accent/30 shadow-lg hover:brightness-110 hover:-translate-y-px"
|
||||
onclick={() => showAddForm = true}
|
||||
>
|
||||
+ New User
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.user-manager {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
{#if showAddForm}
|
||||
<div class="fixed inset-0 bg-black/70 backdrop-blur flex items-center justify-center z-[100]">
|
||||
<form
|
||||
class="bg-bg-card p-6 rounded-[20px] border border-border w-full max-w-sm flex flex-col gap-5 shadow-[0_20px_40px_rgba(0,0,0,0.4)]"
|
||||
onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-text-primary">Add User</h3>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newUserName}
|
||||
placeholder="Name..."
|
||||
class="px-4 py-3 rounded-xl border border-border bg-bg-primary text-text-primary text-sm outline-none focus:border-accent"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg bg-bg-primary text-text-secondary border border-border cursor-pointer transition-all hover:bg-bg-card-hover"
|
||||
onclick={() => { showAddForm = false; newUserName = ''; }}
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 rounded-lg bg-accent text-white border-none font-semibold cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!newUserName.trim()}
|
||||
>Add User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-3">
|
||||
{#each users as user}
|
||||
<div class="flex justify-between items-center px-5 py-4 rounded-2xl bg-bg-card border border-border transition-all hover:border-accent hover:bg-bg-card-hover">
|
||||
{#if editingUserId === user.id}
|
||||
<form class="flex gap-3 w-full" onsubmit={(e) => { e.preventDefault(); handleUpdateUser(); }}>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-accent bg-bg-primary text-text-primary outline-none"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex gap-1.5">
|
||||
<button type="submit" class="px-3 py-1 rounded-md border-none bg-success text-white cursor-pointer transition-all" title="Save">✓</button>
|
||||
<button type="button" class="px-3 py-1 rounded-md border border-border bg-bg-primary text-text-secondary cursor-pointer transition-all" onclick={() => editingUserId = null} title="Cancel">✕</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-semibold text-text-primary">{user.name}</span>
|
||||
<span class="text-[11px] text-text-secondary font-mono uppercase tracking-[0.05em]">ID: {user.id}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="bg-bg-primary border border-border text-text-secondary p-2 rounded-lg cursor-pointer text-sm transition-all hover:border-accent hover:text-accent hover:bg-bg-card"
|
||||
onclick={() => startEditing(user)}
|
||||
title="Rename"
|
||||
>✏️</button>
|
||||
{#if confirmDeleteId === user.id}
|
||||
<button
|
||||
class="bg-danger/10 border border-danger/30 text-danger px-3 py-1 rounded-lg cursor-pointer text-sm transition-all"
|
||||
onclick={() => handleRemoveUser(user.id)}
|
||||
>Delete!</button>
|
||||
<button
|
||||
class="bg-bg-primary border border-border text-text-secondary p-2 rounded-lg cursor-pointer text-sm transition-all hover:bg-bg-card-hover"
|
||||
onclick={() => confirmDeleteId = null}
|
||||
>Cancel</button>
|
||||
{:else}
|
||||
<button
|
||||
class="bg-bg-primary border border-border text-text-secondary p-2 rounded-lg cursor-pointer text-sm transition-all hover:border-danger hover:text-danger hover:bg-danger/10"
|
||||
onclick={() => confirmDeleteId = user.id}
|
||||
title="Delete"
|
||||
>🗑️</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center px-12 py-12 bg-bg-card border border-dashed border-border rounded-2xl text-text-secondary">
|
||||
No users found. Create one to get started!
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
/* Selector Mode Styles */
|
||||
.user-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.user-chip:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text-primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.user-chip.selected {
|
||||
background: color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
box-shadow: 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
|
||||
/* Manager Mode Styles */
|
||||
.manager-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.add-user-btn-large {
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--color-accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.add-user-btn-large:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.user-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-radius: 16px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.user-item:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-card-hover);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-name-large {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.add-user-modal {
|
||||
background: var(--color-bg-card);
|
||||
padding: 24px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Edit Inline Row */
|
||||
.edit-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-accent);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.save-btn, .cancel-btn {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Common UI items */
|
||||
.btn-primary {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.user-error {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 20%, transparent);
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
{#if error}
|
||||
<div class="mt-4 p-3 rounded-xl bg-danger/10 border border-danger/20 text-danger text-[13px] text-center">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
48
Provider/frontend/src/lib/utils.js
Normal file
48
Provider/frontend/src/lib/utils.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared utility functions for the Calendink dashboard.
|
||||
* Import from here — never duplicate these across components.
|
||||
*/
|
||||
|
||||
/** Format uptime seconds into human-readable string */
|
||||
export function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
const parts = [];
|
||||
if (d > 0) parts.push(`${d}d`);
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
parts.push(`${s}s`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** Format bytes into human-readable string */
|
||||
export function formatBytes(bytes) {
|
||||
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/** Format a Unix timestamp (seconds) as a human-readable relative string */
|
||||
export function formatRelativeDate(timestamp) {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = timestamp - now;
|
||||
const absDiff = Math.abs(diff);
|
||||
|
||||
if (absDiff < 3600) {
|
||||
const mins = Math.round(absDiff / 60);
|
||||
return diff < 0 ? `${mins}m ago` : `in ${mins}m`;
|
||||
}
|
||||
if (absDiff < 86400) {
|
||||
const hours = Math.round(absDiff / 3600);
|
||||
return diff < 0 ? `${hours}h ago` : `in ${hours}h`;
|
||||
}
|
||||
const days = Math.round(absDiff / 86400);
|
||||
return diff < 0 ? `${days}d ago` : `in ${days}d`;
|
||||
}
|
||||
|
||||
/** Returns true if a Unix timestamp (seconds) is in the past */
|
||||
export function isOverdue(timestamp) {
|
||||
return timestamp < Date.now() / 1000;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Reference in New Issue
Block a user