Asked claude opus to make an audit of code and use coding guidelines etc. This is the fix
This commit is contained in:
1 parent
75d88f441c
commit
b702839f8e
15 files changed
+454
-1286
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getSystemInfo, reboot, getOTAStatus, getUpcomingTasks } from "./lib/api.js";
|
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 OTAUpdate from "./lib/OTAUpdate.svelte";
|
||||||
import Sidebar from "./lib/Sidebar.svelte";
|
import Sidebar from "./lib/Sidebar.svelte";
|
||||||
import TaskManager from "./lib/TaskManager.svelte";
|
import TaskManager from "./lib/TaskManager.svelte";
|
||||||
@@ -32,49 +33,7 @@
|
|||||||
|
|
||||||
let upcomingData = $state({ users: [] });
|
let upcomingData = $state({ users: [] });
|
||||||
|
|
||||||
/** Format uptime seconds into human-readable string */
|
let isFetching = false; // mutex, not reactive
|
||||||
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 lastKnownFirmware = null;
|
let lastKnownFirmware = null;
|
||||||
let lastKnownSlot = null;
|
let lastKnownSlot = null;
|
||||||
async function fetchAll(silent = false) {
|
async function fetchAll(silent = false) {
|
||||||
@@ -158,7 +117,7 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app-layout">
|
<div class="flex min-h-screen bg-bg-primary">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
{currentView}
|
{currentView}
|
||||||
isOpen={mobileMenuOpen}
|
isOpen={mobileMenuOpen}
|
||||||
@@ -168,25 +127,25 @@
|
|||||||
|
|
||||||
{#if mobileMenuOpen}
|
{#if mobileMenuOpen}
|
||||||
<button
|
<button
|
||||||
class="mobile-backdrop"
|
class="fixed inset-0 bg-black/40 backdrop-blur z-[999] border-none p-0 cursor-pointer"
|
||||||
onclick={() => mobileMenuOpen = false}
|
onclick={() => mobileMenuOpen = false}
|
||||||
aria-label="Close menu"
|
aria-label="Close menu"
|
||||||
></button>
|
></button>
|
||||||
{/if}
|
{/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 -->
|
<!-- 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 -->
|
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||||
<button class="hamburger" onclick={() => mobileMenuOpen = true}>
|
<button class="bg-transparent border-none text-text-primary p-2 cursor-pointer" onclick={() => mobileMenuOpen = true}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
<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="6" x2="21" y2="6"></line>
|
||||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="mobile-title">Calendink</span>
|
<span class="font-bold text-base tracking-[-0.02em] text-accent">Calendink</span>
|
||||||
<div style="width: 40px;"></div> <!-- Spacer -->
|
<div class="w-10"></div> <!-- Spacer -->
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="w-full max-w-6xl mx-auto space-y-8">
|
<div class="w-full max-w-6xl mx-auto space-y-8">
|
||||||
@@ -378,7 +337,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Updates & Maintenance Card -->
|
<!-- Updates & Maintenance Card -->
|
||||||
<OTAUpdate onReboot={() => { status = "rebooting"; isRecovering = true; }} />
|
<OTAUpdate otaInfo={otaStatus} {systemInfo} onReboot={() => { status = "rebooting"; isRecovering = true; }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -426,87 +385,3 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Spinner />
|
<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>
|
|
||||||
@@ -17,3 +17,28 @@
|
|||||||
--color-danger: #ef4444;
|
--color-danger: #ef4444;
|
||||||
--color-danger-hover: #f87171;
|
--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>
|
<script>
|
||||||
let { onReboot = null } = $props();
|
import { uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle } from "./api.js";
|
||||||
import { getOTAStatus, uploadOTAFrontend, uploadOTAFirmware, uploadOTABundle, getSystemInfo } 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;
|
const IS_DEV = import.meta.env.DEV;
|
||||||
|
|
||||||
/** @type {'idle' | 'loading_status' | 'uploading' | 'success' | 'error'} */
|
/** @type {'idle' | 'uploading' | 'success' | 'error'} */
|
||||||
let status = $state("idle");
|
let status = $state("idle");
|
||||||
let errorMsg = $state("");
|
let errorMsg = $state("");
|
||||||
let uploadProgress = $state(0);
|
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 selectedFile = $state(null);
|
||||||
let showAdvanced = $state(false);
|
let showAdvanced = $state(false);
|
||||||
/** @type {'frontend' | 'firmware' | 'bundle'} */
|
/** @type {'frontend' | 'firmware' | 'bundle'} */
|
||||||
let updateMode = $state("frontend");
|
let updateMode = $state("frontend");
|
||||||
let isDragging = $state(false);
|
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) {
|
function handleFileChange(event) {
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
if (files && files.length > 0) processFile(files[0]);
|
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 === 'bundle') return 'FW + UI';
|
||||||
if (updateMode === 'frontend') return otaInfo.target_partition;
|
if (updateMode === 'frontend') return otaInfo.target_partition;
|
||||||
// For firmware, target is the slot that is NOT the running one
|
// For firmware, target is the slot that is NOT the running one
|
||||||
@@ -182,7 +161,7 @@
|
|||||||
OTA Upgrade ({updateMode === 'frontend' ? 'UI' : 'Firmware'})
|
OTA Upgrade ({updateMode === 'frontend' ? 'UI' : 'Firmware'})
|
||||||
</h3>
|
</h3>
|
||||||
<div class="text-[10px] text-text-secondary">
|
<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}
|
{#if updateMode === 'frontend' && otaInfo.partitions}
|
||||||
<span class="ml-1">
|
<span class="ml-1">
|
||||||
({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(1)} MB)
|
({(otaInfo.partitions.find(p => p.label === otaInfo.target_partition)?.size / 1024 / 1024).toFixed(1)} MB)
|
||||||
|
|||||||
@@ -16,17 +16,29 @@
|
|||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="sidebar {collapsed ? 'collapsed' : ''} {isOpen ? 'mobile-open' : ''}">
|
<aside class="
|
||||||
<div class="sidebar-header">
|
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}
|
{#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}
|
{/if}
|
||||||
<button
|
<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)}
|
onclick={() => onToggle ? onToggle() : (collapsed = !collapsed)}
|
||||||
title={collapsed ? 'Expand' : 'Collapse'}
|
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="6" x2="21" y2="6"></line>
|
||||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||||
@@ -34,160 +46,22 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="flex flex-col gap-1 p-2">
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
<button
|
<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)}
|
onclick={() => onNavigate(item.id)}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
>
|
>
|
||||||
<span class="nav-icon">{item.icon}</span>
|
<span class="text-base flex-shrink-0">{item.icon}</span>
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<span class="nav-label">{item.label}</span>
|
<span class="overflow-hidden">{item.label}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</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>
|
<script>
|
||||||
import { pendingRequests } from './stores.js';
|
import { pendingRequests } from './stores.js';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
|
||||||
|
|
||||||
let showSpinner = false;
|
let showSpinner = $state(false);
|
||||||
let timer;
|
let timer = null; // script-only handle, not reactive
|
||||||
|
|
||||||
// Subscribe to the store
|
// Track pending request count and show spinner after 1s delay
|
||||||
const unsubscribe = pendingRequests.subscribe(count => {
|
$effect(() => {
|
||||||
|
const count = $pendingRequests;
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
// Only show the spinner if the request takes longer than 1000ms
|
|
||||||
if (!timer) {
|
if (!timer) {
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => { showSpinner = true; }, 1000);
|
||||||
showSpinner = true;
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Instantly hide the spinner when all requests finish
|
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
@@ -23,27 +19,13 @@
|
|||||||
showSpinner = false;
|
showSpinner = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
unsubscribe();
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showSpinner}
|
{#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="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-surface-base rounded-2xl shadow-xl border border-divider">
|
<div class="flex flex-col items-center p-8 bg-bg-card rounded-2xl shadow-xl border border-border">
|
||||||
<!-- Loading circle animation -->
|
<div class="w-12 h-12 border-4 border-accent border-t-transparent rounded-full animate-spin"></div>
|
||||||
<div class="w-12 h-12 border-4 border-primary 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>
|
<p class="mt-4 text-text-primary font-medium tracking-wide animate-pulse">Communicating with Device...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
<script>
|
||||||
import { getTasks, addTask, updateTask, deleteTask } from './api.js';
|
import { getTasks, addTask, updateTask, deleteTask } from './api.js';
|
||||||
|
import { formatRelativeDate, isOverdue } from './utils.js';
|
||||||
import UserManager from './UserManager.svelte';
|
import UserManager from './UserManager.svelte';
|
||||||
|
|
||||||
let selectedUserId = $state(null);
|
let selectedUserId = $state(null);
|
||||||
@@ -97,7 +98,6 @@
|
|||||||
async function handleAddTask(e) {
|
async function handleAddTask(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newTitle.trim()) return;
|
if (!newTitle.trim()) return;
|
||||||
// For non-recurrent tasks, require a date
|
|
||||||
if (newRecurrence === 0 && !newDueDay) return;
|
if (newRecurrence === 0 && !newDueDay) return;
|
||||||
|
|
||||||
const dueTimestamp = newRecurrence > 0
|
const dueTimestamp = newRecurrence > 0
|
||||||
@@ -183,59 +183,48 @@
|
|||||||
newDueDay = `${year}-${month}-${dayNum}`;
|
newDueDay = `${year}-${month}-${dayNum}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeDate(timestamp) {
|
function taskIsOverdue(task) {
|
||||||
const now = Date.now() / 1000;
|
if (task.recurrence > 0) return false;
|
||||||
const diff = timestamp - now;
|
return isOverdue(task.due_date);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="task-manager">
|
<div class="w-full">
|
||||||
<UserManager bind:selectedUserId onUsersChanged={fetchTasks} />
|
<UserManager bind:selectedUserId onUsersChanged={fetchTasks} />
|
||||||
|
|
||||||
{#if selectedUserId}
|
{#if selectedUserId}
|
||||||
<div class="task-header">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h2 class="task-title">Tasks</h2>
|
<h2 class="text-sm font-semibold uppercase tracking-[0.05em] text-text-primary">Tasks</h2>
|
||||||
{#if !showAddForm}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showAddForm}
|
{#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 -->
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newTitle}
|
bind:value={newTitle}
|
||||||
placeholder="Task title..."
|
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
|
autofocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="field-row">
|
<div class="flex gap-2">
|
||||||
<div class="field-group">
|
<div class="flex flex-col gap-1 flex-1">
|
||||||
<span class="field-label">Period</span>
|
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Period</span>
|
||||||
<div class="period-selector">
|
<div class="flex gap-1">
|
||||||
{#each PERIODS as p, i}
|
{#each PERIODS as p, i}
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onclick={() => newPeriod = toggleBit(newPeriod, i)}
|
||||||
>{PERIOD_ICONS[i]} {p}</button>
|
>{PERIOD_ICONS[i]} {p}</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -243,14 +232,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-row">
|
<div class="flex gap-2">
|
||||||
<div class="field-group">
|
<div class="flex flex-col gap-1 flex-1">
|
||||||
<span class="field-label">Recurrence</span>
|
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Recurrence</span>
|
||||||
<div class="recurrence-selector">
|
<div class="flex gap-[3px] flex-wrap">
|
||||||
{#each DAYS as day, i}
|
{#each DAYS as day, i}
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onclick={() => newRecurrence = toggleBit(newRecurrence, i)}
|
||||||
>{day}</button>
|
>{day}</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -259,36 +251,46 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if newRecurrence === 0}
|
{#if newRecurrence === 0}
|
||||||
<div class="field-row">
|
<div class="flex gap-2">
|
||||||
<label class="field-group">
|
<label class="flex flex-col gap-1 flex-1">
|
||||||
<span class="field-label">Date</span>
|
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Date</span>
|
||||||
<input type="date" bind:value={newDueDay} class="task-date-input" />
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="flex gap-1.5">
|
||||||
<button type="submit" class="btn-primary" disabled={!newTitle.trim() || (newRecurrence === 0 && !newDueDay)}>Add</button>
|
<button type="submit"
|
||||||
<button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newTitle = ''; newDueDay = ''; newRecurrence = 0; newPeriod = 0x01; }}>Cancel</button>
|
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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="task-list">
|
<div class="flex flex-col gap-1">
|
||||||
{#each tasks as task}
|
{#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}
|
{#if editingTaskId === task.id}
|
||||||
<form class="edit-form" onsubmit={saveEdit}>
|
<form class="w-full flex flex-col gap-2 p-0" onsubmit={saveEdit}>
|
||||||
<input type="text" bind:value={editTitle} class="task-input" />
|
<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="flex gap-2">
|
||||||
<div class="field-group">
|
<div class="flex flex-col gap-1 flex-1">
|
||||||
<span class="field-label">Period</span>
|
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Period</span>
|
||||||
<div class="period-selector">
|
<div class="flex gap-1">
|
||||||
{#each PERIODS as p, i}
|
{#each PERIODS as p, i}
|
||||||
<button
|
<button type="button"
|
||||||
type="button"
|
class="flex-1 px-2 py-1.5 rounded-md border cursor-pointer text-[11px] transition-all whitespace-nowrap
|
||||||
class="period-btn {hasBit(editPeriod, i) ? 'active' : ''}"
|
{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)}
|
onclick={() => editPeriod = toggleBit(editPeriod, i)}
|
||||||
>{PERIOD_ICONS[i]} {p}</button>
|
>{PERIOD_ICONS[i]} {p}</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -296,14 +298,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-row">
|
<div class="flex gap-2">
|
||||||
<div class="field-group">
|
<div class="flex flex-col gap-1 flex-1">
|
||||||
<span class="field-label">Recurrence</span>
|
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Recurrence</span>
|
||||||
<div class="recurrence-selector">
|
<div class="flex gap-[3px] flex-wrap">
|
||||||
{#each DAYS as day, i}
|
{#each DAYS as day, i}
|
||||||
<button
|
<button type="button"
|
||||||
type="button"
|
class="px-2 py-[5px] rounded-md border cursor-pointer text-[11px] transition-all min-w-8 text-center
|
||||||
class="rec-btn {hasBit(editRecurrence, i) ? 'active' : ''}"
|
{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)}
|
onclick={() => editRecurrence = toggleBit(editRecurrence, i)}
|
||||||
>{day}</button>
|
>{day}</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -312,425 +316,71 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if editRecurrence === 0}
|
{#if editRecurrence === 0}
|
||||||
<div class="field-row">
|
<div class="flex gap-2">
|
||||||
<label class="field-group">
|
<label class="flex flex-col gap-1 flex-1">
|
||||||
<span class="field-label">Date</span>
|
<span class="text-[10px] font-semibold uppercase tracking-[0.05em] text-text-secondary">Date</span>
|
||||||
<input type="date" bind:value={editDueDay} class="task-date-input" />
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="flex gap-1.5">
|
||||||
<button type="submit" class="btn-primary btn-sm">Save</button>
|
<button type="submit"
|
||||||
<button type="button" class="btn-secondary btn-sm" onclick={() => editingTaskId = null}>Cancel</button>
|
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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="task-left">
|
<div class="flex items-center gap-2.5 flex-1 min-w-0">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={task.completed}
|
checked={task.completed}
|
||||||
onchange={() => handleToggleComplete(task)}
|
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">
|
<div class="flex flex-col gap-0.5 min-w-0">
|
||||||
<span class="task-text">{task.title}</span>
|
<span class="text-[13px] text-text-primary whitespace-nowrap overflow-hidden text-ellipsis
|
||||||
<div class="task-meta">
|
{task.completed ? 'line-through text-text-secondary' : ''}">{task.title}</span>
|
||||||
<span class="task-period">{periodIcons(task.period)} {formatPeriod(task.period)}</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}
|
{#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}
|
{: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)}
|
{formatRelativeDate(task.due_date)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-actions">
|
<div class="flex gap-1 items-center flex-shrink-0">
|
||||||
<button class="action-btn" onclick={() => startEditing(task)} title="Edit">✏️</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={() => startEditing(task)} title="Edit">✏️</button>
|
||||||
{#if confirmDeleteId === task.id}
|
{#if confirmDeleteId === task.id}
|
||||||
<button class="action-btn text-danger" onclick={() => handleDelete(task.id)} title="Confirm">✓</button>
|
<button class="bg-transparent border-none cursor-pointer text-xs p-1 rounded text-danger opacity-60 hover:opacity-100"
|
||||||
<button class="action-btn" onclick={() => confirmDeleteId = null} title="Cancel">✕</button>
|
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}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
||||||
|
|
||||||
{#if error}
|
{#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}
|
{/if}
|
||||||
</div>
|
</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>
|
<script>
|
||||||
import { getUsers, addUser, removeUser, updateUser } from './api.js';
|
import { getUsers, addUser, removeUser, updateUser } from './api.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
selectedUserId = $bindable(null),
|
selectedUserId = $bindable(null),
|
||||||
onUsersChanged = () => {},
|
onUsersChanged = () => {},
|
||||||
mode = 'selector' // 'selector' | 'manager'
|
mode = 'selector' // 'selector' | 'manager'
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let users = $state([]);
|
let users = $state([]);
|
||||||
let newUserName = $state('');
|
let newUserName = $state('');
|
||||||
let showAddForm = $state(false);
|
let showAddForm = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let confirmDeleteId = $state(null);
|
let confirmDeleteId = $state(null);
|
||||||
|
|
||||||
// Edit state
|
// Edit state
|
||||||
let editingUserId = $state(null);
|
let editingUserId = $state(null);
|
||||||
let editName = $state('');
|
let editName = $state('');
|
||||||
|
|
||||||
async function fetchUsers() {
|
async function fetchUsers() {
|
||||||
try {
|
try {
|
||||||
users = await getUsers();
|
users = await getUsers();
|
||||||
if (users.length > 0 && !selectedUserId && mode === 'selector') {
|
if (users.length > 0 && !selectedUserId && mode === 'selector') {
|
||||||
selectedUserId = users[0].id;
|
selectedUserId = users[0].id;
|
||||||
}
|
}
|
||||||
// If selected user was deleted, select first available
|
// If selected user was deleted, select first available
|
||||||
if (selectedUserId && !users.find(u => u.id === selectedUserId)) {
|
if (selectedUserId && !users.find(u => u.id === selectedUserId)) {
|
||||||
selectedUserId = users.length > 0 ? users[0].id : null;
|
selectedUserId = users.length > 0 ? users[0].id : null;
|
||||||
}
|
}
|
||||||
error = '';
|
error = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddUser() {
|
async function handleAddUser() {
|
||||||
if (!newUserName.trim()) return;
|
if (!newUserName.trim()) return;
|
||||||
try {
|
try {
|
||||||
const user = await addUser(newUserName.trim());
|
const user = await addUser(newUserName.trim());
|
||||||
newUserName = '';
|
newUserName = '';
|
||||||
showAddForm = false;
|
showAddForm = false;
|
||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
if (mode === 'selector') selectedUserId = user.id;
|
if (mode === 'selector') selectedUserId = user.id;
|
||||||
onUsersChanged();
|
onUsersChanged();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemoveUser(id) {
|
async function handleRemoveUser(id) {
|
||||||
try {
|
try {
|
||||||
await removeUser(id);
|
await removeUser(id);
|
||||||
confirmDeleteId = null;
|
confirmDeleteId = null;
|
||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
onUsersChanged();
|
onUsersChanged();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEditing(user) {
|
function startEditing(user) {
|
||||||
editingUserId = user.id;
|
editingUserId = user.id;
|
||||||
editName = user.name;
|
editName = user.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateUser() {
|
async function handleUpdateUser() {
|
||||||
if (!editName.trim()) return;
|
if (!editName.trim()) return;
|
||||||
try {
|
try {
|
||||||
await updateUser(editingUserId, editName.trim());
|
await updateUser(editingUserId, editName.trim());
|
||||||
editingUserId = null;
|
editingUserId = null;
|
||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
onUsersChanged();
|
onUsersChanged();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
// Load initial data on mount
|
||||||
fetchUsers();
|
$effect(() => {
|
||||||
});
|
fetchUsers();
|
||||||
</script>
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<div class="user-manager mode-{mode}">
|
<div class="mb-4">
|
||||||
{#if mode === 'selector'}
|
{#if mode === 'selector'}
|
||||||
<div class="user-bar">
|
<div class="flex items-center gap-2">
|
||||||
<div class="user-chips">
|
<div class="flex flex-wrap gap-1.5 items-center">
|
||||||
{#each users as user}
|
{#each users as user}
|
||||||
<!-- svelte-ignore node_invalid_placement_ssr -->
|
<!-- svelte-ignore node_invalid_placement_ssr -->
|
||||||
<div
|
<div
|
||||||
class="user-chip {selectedUserId === user.id ? 'selected' : ''}"
|
class="flex items-center gap-1.5 px-4 py-1.5 rounded-full border cursor-pointer text-[13px] font-semibold
|
||||||
onclick={() => selectedUserId = user.id}
|
transition-all duration-200 hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)]
|
||||||
role="button"
|
{selectedUserId === user.id
|
||||||
tabindex="0"
|
? 'bg-accent/20 border-accent text-accent ring-1 ring-accent'
|
||||||
onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }}
|
: 'border-border bg-bg-card text-text-secondary hover:border-accent hover:text-text-primary'}"
|
||||||
>
|
onclick={() => selectedUserId = user.id}
|
||||||
<span class="user-name">{user.name}</span>
|
role="button"
|
||||||
</div>
|
tabindex="0"
|
||||||
{/each}
|
onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }}
|
||||||
</div>
|
>
|
||||||
</div>
|
<span>{user.name}</span>
|
||||||
{:else}
|
</div>
|
||||||
<!-- Manager Mode -->
|
{/each}
|
||||||
<header class="manager-header">
|
</div>
|
||||||
<h1 class="text-xl font-bold text-text-primary">User Management</h1>
|
</div>
|
||||||
<button class="add-user-btn-large" onclick={() => showAddForm = true}>
|
{:else}
|
||||||
+ New User
|
<!-- Manager Mode -->
|
||||||
</button>
|
<header class="flex justify-between items-center mb-6">
|
||||||
</header>
|
<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>
|
||||||
|
|
||||||
{#if showAddForm}
|
{#if showAddForm}
|
||||||
<div class="modal-overlay">
|
<div class="fixed inset-0 bg-black/70 backdrop-blur flex items-center justify-center z-[100]">
|
||||||
<form class="add-user-modal" onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}>
|
<form
|
||||||
<h3 class="text-lg font-semibold">Add User</h3>
|
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)]"
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}
|
||||||
<input
|
>
|
||||||
type="text"
|
<h3 class="text-lg font-semibold text-text-primary">Add User</h3>
|
||||||
bind:value={newUserName}
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
placeholder="Name..."
|
<input
|
||||||
class="modal-input"
|
type="text"
|
||||||
autofocus
|
bind:value={newUserName}
|
||||||
/>
|
placeholder="Name..."
|
||||||
<div class="modal-actions">
|
class="px-4 py-3 rounded-xl border border-border bg-bg-primary text-text-primary text-sm outline-none focus:border-accent"
|
||||||
<button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newUserName = ''; }}>Cancel</button>
|
autofocus
|
||||||
<button type="submit" class="btn-primary" disabled={!newUserName.trim()}>Add User</button>
|
/>
|
||||||
</div>
|
<div class="flex gap-3 justify-end">
|
||||||
</form>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
{/if}
|
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="user-list">
|
<div class="grid gap-3">
|
||||||
{#each users as user}
|
{#each users as user}
|
||||||
<div class="user-item">
|
<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}
|
{#if editingUserId === user.id}
|
||||||
<form class="edit-row" onsubmit={(e) => { e.preventDefault(); handleUpdateUser(); }}>
|
<form class="flex gap-3 w-full" onsubmit={(e) => { e.preventDefault(); handleUpdateUser(); }}>
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input type="text" bind:value={editName} class="edit-input" autofocus />
|
<input
|
||||||
<div class="edit-actions">
|
type="text"
|
||||||
<button type="submit" class="save-btn" title="Save">✓</button>
|
bind:value={editName}
|
||||||
<button type="button" class="cancel-btn" onclick={() => editingUserId = null} title="Cancel">✕</button>
|
class="flex-1 px-3 py-2 rounded-lg border border-accent bg-bg-primary text-text-primary outline-none"
|
||||||
</div>
|
autofocus
|
||||||
</form>
|
/>
|
||||||
{:else}
|
<div class="flex gap-1.5">
|
||||||
<div class="user-info">
|
<button type="submit" class="px-3 py-1 rounded-md border-none bg-success text-white cursor-pointer transition-all" title="Save">✓</button>
|
||||||
<span class="user-name-large">{user.name}</span>
|
<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>
|
||||||
<span class="user-id">ID: {user.id}</span>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
<div class="user-actions">
|
{:else}
|
||||||
<button class="action-btn" onclick={() => startEditing(user)} title="Rename">✏️</button>
|
<div class="flex flex-col gap-1">
|
||||||
{#if confirmDeleteId === user.id}
|
<span class="text-base font-semibold text-text-primary">{user.name}</span>
|
||||||
<button class="action-btn danger" onclick={() => handleRemoveUser(user.id)}>Delete!</button>
|
<span class="text-[11px] text-text-secondary font-mono uppercase tracking-[0.05em]">ID: {user.id}</span>
|
||||||
<button class="action-btn" onclick={() => confirmDeleteId = null}>Cancel</button>
|
</div>
|
||||||
{:else}
|
<div class="flex gap-2">
|
||||||
<button class="action-btn" onclick={() => confirmDeleteId = user.id} title="Delete">🗑️</button>
|
<button
|
||||||
{/if}
|
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"
|
||||||
</div>
|
onclick={() => startEditing(user)}
|
||||||
{/if}
|
title="Rename"
|
||||||
</div>
|
>✏️</button>
|
||||||
{:else}
|
{#if confirmDeleteId === user.id}
|
||||||
<div class="empty-state">No users found. Create one to get started!</div>
|
<button
|
||||||
{/each}
|
class="bg-danger/10 border border-danger/30 text-danger px-3 py-1 rounded-lg cursor-pointer text-sm transition-all"
|
||||||
</div>
|
onclick={() => handleRemoveUser(user.id)}
|
||||||
{/if}
|
>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}
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="user-error">{error}</div>
|
<div class="mt-4 p-3 rounded-xl bg-danger/10 border border-danger/20 text-danger text-[13px] text-center">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
.user-manager {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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>
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Calendink Provider</title>
|
|
||||||
<script type="module">const kr=!1;var en=Array.isArray,Jn=Array.prototype.indexOf,Rt=Array.prototype.includes,pr=Array.from,Zn=Object.defineProperty,Nt=Object.getOwnPropertyDescriptor,Xn=Object.getOwnPropertyDescriptors,Qn=Object.prototype,ea=Array.prototype,tn=Object.getPrototypeOf,Hr=Object.isExtensible;const ta=()=>{};function ra(e){for(var t=0;t<e.length;t++)e[t]()}function rn(){var e,t,r=new Promise((n,a)=>{e=n,t=a});return{promise:r,resolve:e,reject:t}}function na(e,t){if(Array.isArray(e))return e;if(!(Symbol.iterator in e))return Array.from(e);const r=[];for(const n of e)if(r.push(n),r.length===t)break;return r}const ce=2,nr=4,tr=8,nn=1<<24,xt=16,Ye=32,Ot=64,Tr=128,Le=512,fe=1024,ve=2048,We=4096,Me=8192,nt=16384,qt=32768,Ut=65536,qr=1<<17,aa=1<<18,Vt=1<<19,sa=1<<20,rt=1<<25,It=65536,Er=1<<21,Fr=1<<22,ht=1<<23,ur=Symbol("$state"),ia=Symbol("legacy props"),oa=Symbol(""),Et=new class extends Error{name="StaleReactionError";message="The reaction that called `getAbortSignal()` was re-run or destroyed"};function la(){throw new Error("https://svelte.dev/e/async_derived_orphan")}function fa(e,t,r){throw new Error("https://svelte.dev/e/each_key_duplicate")}function ua(e){throw new Error("https://svelte.dev/e/effect_in_teardown")}function da(){throw new Error("https://svelte.dev/e/effect_in_unowned_derived")}function ca(e){throw new Error("https://svelte.dev/e/effect_orphan")}function va(){throw new Error("https://svelte.dev/e/effect_update_depth_exceeded")}function pa(e){throw new Error("https://svelte.dev/e/props_invalid_value")}function _a(){throw new Error("https://svelte.dev/e/state_descriptors_fixed")}function ha(){throw new Error("https://svelte.dev/e/state_prototype_fixed")}function ba(){throw new Error("https://svelte.dev/e/state_unsafe_mutation")}function ma(){throw new Error("https://svelte.dev/e/svelte_boundary_reset_onerror")}const ga=1,xa=2,an=4,ya=8,wa=16,ka=1,Ta=4,Ea=8,Sa=16,$a=1,Aa=2,de=Symbol(),sn="http://www.w3.org/1999/xhtml";function Ca(){console.warn("https://svelte.dev/e/svelte_boundary_reset_noop")}function on(e){return e===this.v}function Da(e,t){return e!=e?t==t:e!==t||e!==null&&typeof e=="object"||typeof e=="function"}function ln(e){return!Da(e,this.v)}let Ia=!1,Be=null;function Lt(e){Be=e}function Wt(e,t=!1,r){Be={p:Be,i:!1,c:null,e:null,s:e,x:null,l:null}}function Yt(e){var t=Be,r=t.e;if(r!==null){t.e=null;for(var n of r)Cn(n)}return t.i=!0,Be=t.p,{}}function fn(){return!0}let St=[];function un(){var e=St;St=[],ra(e)}function at(e){if(St.length===0&&!Qt){var t=St;queueMicrotask(()=>{t===St&&un()})}St.push(e)}function Ma(){for(;St.length>0;)un()}function dn(e){var t=U;if(t===null)return P.f|=ht,e;if((t.f&qt)===0&&(t.f&nr)===0)throw e;ct(e,t)}function ct(e,t){for(;t!==null;){if((t.f&Tr)!==0){if((t.f&qt)===0)throw e;try{t.b.error(e);return}catch(r){e=r}}t=t.parent}throw e}const Oa=-7169;function re(e,t){e.f=e.f&Oa|t}function Nr(e){(e.f&Le)!==0||e.deps===null?re(e,fe):re(e,We)}function cn(e){if(e!==null)for(const t of e)(t.f&ce)===0||(t.f&It)===0||(t.f^=It,cn(t.deps))}function vn(e,t,r){(e.f&ve)!==0?t.add(e):(e.f&We)!==0&&r.add(e),cn(e.deps),re(e,fe)}const or=new Set;let q=null,Sr=null,He=null,Te=[],_r=null,Qt=!1,jt=null;class bt{current=new Map;previous=new Map;#e=new Set;#o=new Set;#t=0;#i=0;#r=null;#s=new Set;#n=new Set;#a=new Map;is_fork=!1;#l=!1;#u(){return this.is_fork||this.#i>0}skip_effect(t){this.#a.has(t)||this.#a.set(t,{d:[],m:[]})}unskip_effect(t){var r=this.#a.get(t);if(r){this.#a.delete(t);for(var n of r.d)re(n,ve),qe(n);for(n of r.m)re(n,We),qe(n)}}process(t){Te=[],this.apply();var r=jt=[],n=[];for(const a of t)this.#f(a,r,n);if(jt=null,this.#u()){this.#d(n),this.#d(r);for(const[a,s]of this.#a)bn(a,s)}else{Sr=this,q=null;for(const a of this.#e)a(this);this.#e.clear(),this.#t===0&&this.#c(),Vr(n),Vr(r),this.#s.clear(),this.#n.clear(),Sr=null,this.#r?.resolve()}He=null}#f(t,r,n){t.f^=fe;for(var a=t.first;a!==null;){var s=a.f,l=(s&(Ye|Ot))!==0,u=l&&(s&fe)!==0,o=u||(s&Me)!==0||this.#a.has(a);if(!o&&a.fn!==null){l?a.f^=fe:(s&nr)!==0?r.push(a):sr(a)&&((s&xt)!==0&&this.#n.add(a),Ht(a));var f=a.first;if(f!==null){a=f;continue}}for(;a!==null;){var d=a.next;if(d!==null){a=d;break}a=a.parent}}}#d(t){for(var r=0;r<t.length;r+=1)vn(t[r],this.#s,this.#n)}capture(t,r){r!==de&&!this.previous.has(t)&&this.previous.set(t,r),(t.f&ht)===0&&(this.current.set(t,t.v),He?.set(t,t.v))}activate(){q=this,this.apply()}deactivate(){q===this&&(q=null,He=null)}flush(){if(Te.length>0)q=this,pn();else if(this.#t===0&&!this.is_fork){for(const t of this.#e)t(this);this.#e.clear(),this.#c(),this.#r?.resolve()}this.deactivate()}discard(){for(const t of this.#o)t(this);this.#o.clear()}#c(){if(or.size>1){this.previous.clear();var t=q,r=He,n=!0;for(const s of or){if(s===this){n=!1;continue}const l=[];for(const[o,f]of this.current){if(s.current.has(o))if(n&&f!==s.current.get(o))s.current.set(o,f);else continue;l.push(o)}if(l.length===0)continue;const u=[...s.current.keys()].filter(o=>!this.current.has(o));Line truncated
|
|
||||||
${i(b)?"border-accent bg-accent/5":"border-border hover:border-border-hover bg-bg-primary/20"}
|
|
||||||
${i(n)==="uploading"?"opacity-50 pointer-events-none":""}`),Or(Q,"accept",i(d)==="bundle"?".bundle":".bin"),O(K,i(d)==="frontend"?"🎨":"⚙️")}),Dt("dragover",B,H=>{H.preventDefault(),c(b,!0)}),Dt("dragleave",B,()=>c(b,!1)),Dt("drop",B,g),z("change",Q,x),y(be,X)};N(ot,be=>{i(n)==="success"?be(Oe):be(Pt,!1)})}R(be=>{O(ie,`OTA Upgrade (${i(d)==="frontend"?"UI":"Firmware"})`),O(Ze,be)},[()=>i(I)()]),y(Y,M)};N(j,Y=>{i(f)&&Y(J)})}R(()=>{Se(Z,1,`text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
|
|
||||||
${i(f)&&i(d)==="frontend"?"bg-accent text-white":"bg-border text-text-secondary hover:text-text-primary"}`),Se(pe,1,`text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
|
|
||||||
${i(f)&&i(d)==="firmware"?"bg-accent text-white":"bg-border text-text-secondary hover:text-text-primary"}`),Se(we,1,`text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
|
|
||||||
${i(f)&&i(d)==="bundle"?"bg-accent text-white":"bg-border text-text-secondary hover:text-text-primary"}`),O(le,`Slot: ${i(l).active_partition??""} `),O(ae,i(u).firmware),O($,`Active: ${i(l).active_slot===0?"ota_0":"ota_1"}`)}),z("click",Z,()=>E("frontend")),z("click",pe,()=>E("firmware")),z("click",we,()=>E("bundle")),y(C,w)};N(V,C=>{C(te)})}y(e,L),Yt()}ir(["click","change"]);var ei=k('<span class="sidebar-title svelte-1nhzsi7">Menu</span>'),ti=k('<span class="nav-label svelte-1nhzsi7"> </span>'),ri=k('<button><span class="nav-icon svelte-1nhzsi7"> </span> <!></button>'),ni=k('<aside><div class="sidebar-header svelte-1nhzsi7"><!> <button><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="svelte-1nhzsi7"><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></div> <nav class="sidebar-nav svelte-1nhzsi7"></nav></aside>');function ai(e,t){Wt(t,!0);let r=_t(t,"currentView",3,"dashboard"),n=_t(t,"onNavigate",3,()=>{}),a=_t(t,"isOpen",3,!1),s=_t(t,"onToggle",3,null),l=A(!1);zt(()=>{typeof window<"u"&&window.innerWidth<=1024&&window.innerWidth>768&&c(l,!0)});const u=[{id:"dashboard",label:"Dashboard",icon:"🏠"},{id:"tasks",label:"Tasks",icon:"📋"},{id:"users",label:"Users",icon:"👥"}];var o=ni(),f=v(o),d=v(f);{var b=g=>{var S=ei();y(g,S)};N(d,g=>{i(l)||g(b)})}var _=h(d,2),x=h(f,2);pt(x,21,()=>u,vt,(g,S)=>{var m=ri(),E=v(m),I=v(E),L=h(E,2);{var V=te=>{var C=ti(),w=v(C);R(()=>O(w,i(S).label)),y(te,C)};N(L,te=>{i(l)||te(V)})}R(()=>{Se(m,1,`nav-item ${r()===i(S).id?"active":""}`,"svelte-1nhzsi7"),Or(m,"title",i(S).label),O(I,i(S).icon)}),z("click",m,()=>n()(i(S).id)),y(g,m)}),R(()=>{Se(o,1,`sidebar ${i(l)?"collapsed":""} ${a()?"mobile-open":""}`,"svelte-1nhzsi7"),Se(_,1,`collapse-btn ${s()?"mobile-only":""}`,"svelte-1nhzsi7"),Or(_,"title",i(l)?"Expand":"Collapse")}),z("click",_,()=>s()?s()():c(l,!i(l))),y(e,o),Yt()}ir(["click"]);var si=k('<div role="button" tabindex="0"><span class="user-name"> </span></div>'),ii=k('<div class="user-bar svelte-mekxsd"><div class="user-chips svelte-mekxsd"></div></div>'),oi=k('<div class="modal-overlay svelte-mekxsd"><form class="add-user-modal svelte-mekxsd"><h3 class="text-lg font-semibold">Add User</h3> <input type="text" placeholder="Name..." class="modal-input svelte-mekxsd"/> <div class="modal-actions svelte-mekxsd"><button type="button" class="btn-secondary svelte-mekxsd">Cancel</button> <button type="submit" class="btn-primary svelte-mekxsd">Add User</button></div></form></div>'),li=k('<form class="edit-row svelte-mekxsd"><input type="text" class="edit-input svelte-mekxsd"/> <div class="edit-actions svelte-mekxsd"><button type="submit" class="save-btn svelte-mekxsd" title="Save">✓</button> <button type="button" class="cancel-btn svelte-mekxsd" title="Cancel">✕</button></div></form>'),fi=k('<button class="action-btn danger svelte-mekxsd">Delete!</button> <button class="action-btn svelte-mekxsd">Cancel</button>',1),ui=k('<button class="action-btn svelte-mekxsd" title="Delete">🗑️</button>'),di=k('<div class="user-info svelte-mekxsd"><span class="user-name-large svelte-mekxsd"> </span> <span class="user-id svelte-mekxsd"> </span></div> <div class="user-actions svelte-mekxsd"><button class="action-btn svelte-mekxsd" title="Rename">✏️</button> <!></div>',1),ci=k('<div class="user-item svelte-mekxsd"><!></div>'),vi=k('<div class="empty-state svelte-mekxsd">No users found. Create one to get started!</div>'),pi=k('<header class="manager-header svelte-mekxsd"><h1 class="text-xl font-bold text-text-primary">User Management</h1> <button class="add-user-btn-large svelte-mekxsd">+ New User</button></header> <!> <div class="user-list svelte-mekxsd"></div>',1),_i=k('<div class="user-error svelte-mekxsd"> </div>'),hi=k("<div><!> <!></div>");function Kn(e,t){Wt(t,!0);let r=_t(t,"selectedUserId",15,null),n=_t(t,"onUsersChanged",3,()=>{}),a=_t(t,"mode",3,"selector"),s=A(Ie([])),l=A(""),u=A(!1),o=A(""),f=A(null),d=A(null),b=A("");async function _(){try{c(s,await Fs(),!0),i(s).length>0&&!r()&&a()==="selector"&&r(i(s)[0].id),r()&&!i(s).find(w=>w.id===r())&&r(i(s).length>0?i(s)[0].id:null),c(o,"")}catch(w){c(o,w.message,!0)}}async function x(){if(i(l).trim())try{const w=await Ns(i(l).trim());c(l,""),c(u,!1),await _(),a()==="selector"&&r(w.id),n()()}catch(w){c(o,w.message,!0)}}async function g(w){try{await Rs(w),c(f,null),await _(),n()()}catch(F){c(o,F.message,!0)}}function S(w){c(d,w.id,!0),c(b,w.name,!0)}async function m(){if(i(b).trim())try{await Us(i(d),i(b).trim()),c(d,null),await _(),n()()}catch(w){c(o,w.message,!0)}}zt(()=>{_()});var E=hi(),I=v(E);{var L=w=>{var F=ii(),ue=v(F);pt(ue,21,()=>i(s),vt,(Z,pe)=>{var we=si(),Je=v(we),_e=v(Je);R(()=>{Se(we,1,`user-chip ${r()===i(pe).id?"selected":""}`,"svelte-mekxsd"),O(_e,i(pe).name)}),z("click",we,()=>r(i(pe).id)),z("keydown",we,T=>{T.key==="Enter"&&r(i(pe).id)}),y(Z,Line truncated
|
|
||||||
bg-danger/10 text-danger border border-danger/20
|
|
||||||
hover:bg-danger/20 hover:border-danger/30
|
|
||||||
disabled:opacity-40 disabled:cursor-not-allowed">Reboot</button></div></div> <!></div></div>`,1),Ki=k('<div class="bg-bg-card border border-border rounded-xl p-6 shadow-xl"><!></div>'),Gi=k('<div class="bg-bg-card border border-border rounded-xl p-8 shadow-xl"><!></div>'),Ji=k('<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 backdrop-blur-sm"><div class="bg-bg-card border border-border rounded-xl p-6 max-w-sm w-full space-y-4 shadow-2xl"><h3 class="text-lg font-semibold text-text-primary">Confirm Reboot</h3> <p class="text-sm text-text-secondary">Are you sure you want to reboot the ESP32? The device will be temporarily unavailable.</p> <div class="flex gap-3 justify-end"><button class="px-4 py-2 text-sm rounded-lg bg-border/30 text-text-secondary hover:bg-border/50 transition-colors">Cancel</button> <button class="px-4 py-2 text-sm font-medium rounded-lg bg-danger text-white hover:bg-danger-hover transition-colors shadow-lg shadow-danger/20">Reboot Now</button></div></div></div>'),Zi=k('<div class="app-layout svelte-1n46o8q"><!> <!> <main class="main-content svelte-1n46o8q"><header class="mobile-header svelte-1n46o8q"><button class="hamburger svelte-1n46o8q"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="svelte-1n46o8q"><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 svelte-1n46o8q">Calendink</span> <div style="width: 40px;"></div></header> <div class="w-full max-w-6xl mx-auto space-y-8"><div class="text-center"><h1 class="text-2xl font-bold text-accent">Calendink Provider 📅📅🚀🚀⚡</h1> <p class="text-text-secondary text-sm"></p> <div class="flex justify-center mt-4"><!></div></div> <!> <!></div></main></div>');function Xi(e,t){Wt(t,!0);let r=A("loading"),n=A(""),a=A(!1),s=A(!1),l=A("dashboard"),u=A(!1),o=A(Ie({chip:"—",freeHeap:0,uptime:0,firmware:"—",connection:"—"})),f=A(Ie({partitions:[],active_partition:"—",running_firmware_label:"—"})),d=A(Ie({users:[]}));function b(p){const $=Math.floor(p/86400),j=Math.floor(p%86400/3600),J=Math.floor(p%3600/60),Y=p%60,M=[];return $>0&&M.push(`${$}d`),j>0&&M.push(`${j}h`),J>0&&M.push(`${J}m`),M.push(`${Y}s`),M.join(" ")}function _(p){return p>=1048576?`${(p/1048576).toFixed(1)} MB`:p>=1024?`${(p/1024).toFixed(1)} KB`:`${p} B`}function x(p){const $=Date.now()/1e3,j=p-$,J=Math.abs(j);if(J<3600){const M=Math.round(J/60);return j<0?`${M}m ago`:`in ${M}m`}if(J<86400){const M=Math.round(J/3600);return j<0?`${M}h ago`:`in ${M}h`}const Y=Math.round(J/86400);return j<0?`${Y}d ago`:`in ${Y}d`}function g(p){return p<Date.now()/1e3}async function S(){try{const[p,$,j]=await Promise.all([Pr(),Yn(),js().catch(()=>({users:[]}))]);c(o,p,!0),c(f,$,!0),c(d,j,!0),c(r,"ok"),c(n,"")}catch(p){i(s)||(c(r,"error"),c(n,p.message||"Connection failed",!0))}}async function m(){c(a,!1),c(r,"rebooting"),c(s,!0);try{await Is()}catch{}}zt(()=>{S();const p=setInterval(S,5e3);return()=>clearInterval(p)}),zt(()=>{if(i(s)){const p=setInterval(async()=>{try{await Pr()&&(console.log("Device back online! Refreshing UI..."),window.location.reload())}catch{console.log("Waiting for device...")}},2e3);return()=>clearInterval(p)}});const E=mn(()=>[{label:"Chip",value:i(o).chip,icon:"🔧"},{label:"Free Heap",value:_(i(o).freeHeap),icon:"💾"},{label:"Uptime",value:b(i(o).uptime),icon:"⏱️"},{label:"Firmware",value:i(o).firmware,icon:"📦"},{label:"Connection",value:i(o).connection,icon:"🌐"}]);var I=Zi(),L=v(I);ai(L,{get currentView(){return i(l)},get isOpen(){return i(u)},onNavigate:p=>{c(l,p,!0),c(u,!1)},onToggle:()=>c(u,!i(u))});var V=h(L,2);{var te=p=>{var $=Di();z("click",$,()=>c(u,!1)),y(p,$)};N(V,p=>{i(u)&&p(te)})}var C=h(V,2),w=v(C),F=v(w),ue=h(w,2),Z=v(ue),pe=h(v(Z),2);pe.textContent="ESP32-S3 System Dashboard v0.1.16";var we=h(pe,2),Je=v(we);{var _e=p=>{var $=Ii();y(p,$)},T=p=>{var $=Mi();y(p,$)},D=p=>{var $=Oi();y(p,$)},W=p=>{var $=Pi(),j=h(v($),2),J=v(j);R(()=>O(J,`Offline — ${i(n)??""}`)),y(p,$)};N(Je,p=>{i(r)==="loading"?p(_e):i(r)==="ok"?p(T,1):i(r)==="rebooting"?p(D,2):p(W,!1)})}var le=h(Z,2);{var ge=p=>{var $=Yi(),j=Ue($);{var J=X=>{var B=Li(),Q=h(v(B),2);pt(Q,21,()=>i(d).users,vt,(Pe,K)=>{var Xe=Ui(),Qe=v(Xe),lt=v(Qe),Ke=h(Qe,2);{var kt=Fe=>{var xe=Fi();y(Fe,xe)},Tt=Fe=>{var xe=Ri();pt(xe,21,()=>i(K).tasks,vt,(H,ee)=>{var me=Ni(),et=v(me),Kt=v(et),Ft=h(et,2),Gt=v(Ft);R((mr,gr)=>{Se(et,1,`text-[10px] mt-0.5 ${mr??""} font-mono whitespace-nowrap`),O(Kt,gr),O(Gt,i(ee).title)},[()=>g(i(ee).due_date)?"text-danger":"text-text-secondary",()=>x(i(ee).due_date)]),y(H,me)}),y(Fe,xe)};N(Ke,Fe=>{i(K).tasks.length===0?Fe(kt):Fe(Tt,!1)})}R(()=>O(lt,i(K).name)),y(Pe,Xe)}),y(X,B)};N(j,X=>{i(d).users.length>0&&X(J)})}var Y=h(j,2),M=v(Y),se=v(M),ke=h(v(se),2);pt(ke,21,()=>i(E),vt,(X,B)=>{var Q=Bi(),Pe=v(Q),K=v(Pe),Xe=v(K),Qe=h(K,2),lt=v(Qe),Ke=hLine truncated
|
|
||||||
<style rel="stylesheet" crossorigin>@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-divide-x-reverse:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-xl:36rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-wider:.05em;--leading-tight:1.25;--radius-lg:.5rem;--radius-xl:.75rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-bg-primary:#0f172a;--color-bg-card:#1e293b;--color-bg-card-hover:#263548;--color-text-primary:#f1f5f9;--color-text-secondary:#94a3b8;--color-accent:#38bdf8;--color-border:#334155;--color-success:#22c55e;--color-danger:#ef4444;--color-danger-hover:#f87171}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacityLine truncated
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
@@ -1,21 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Calendink Provider</title>
|
|
||||||
<script type="module">const kr=!1;var en=Array.isArray,Jn=Array.prototype.indexOf,Rt=Array.prototype.includes,pr=Array.from,Zn=Object.defineProperty,Nt=Object.getOwnPropertyDescriptor,Xn=Object.getOwnPropertyDescriptors,Qn=Object.prototype,ea=Array.prototype,tn=Object.getPrototypeOf,Hr=Object.isExtensible;const ta=()=>{};function ra(e){for(var t=0;t<e.length;t++)e[t]()}function rn(){var e,t,r=new Promise((n,a)=>{e=n,t=a});return{promise:r,resolve:e,reject:t}}function na(e,t){if(Array.isArray(e))return e;if(!(Symbol.iterator in e))return Array.from(e);const r=[];for(const n of e)if(r.push(n),r.length===t)break;return r}const ce=2,nr=4,tr=8,nn=1<<24,xt=16,Ye=32,Ot=64,Tr=128,Le=512,fe=1024,ve=2048,We=4096,Me=8192,nt=16384,qt=32768,Ut=65536,qr=1<<17,aa=1<<18,Vt=1<<19,sa=1<<20,rt=1<<25,It=65536,Er=1<<21,Fr=1<<22,ht=1<<23,ur=Symbol("$state"),ia=Symbol("legacy props"),oa=Symbol(""),Et=new class extends Error{name="StaleReactionError";message="The reaction that called `getAbortSignal()` was re-run or destroyed"};function la(){throw new Error("https://svelte.dev/e/async_derived_orphan")}function fa(e,t,r){throw new Error("https://svelte.dev/e/each_key_duplicate")}function ua(e){throw new Error("https://svelte.dev/e/effect_in_teardown")}function da(){throw new Error("https://svelte.dev/e/effect_in_unowned_derived")}function ca(e){throw new Error("https://svelte.dev/e/effect_orphan")}function va(){throw new Error("https://svelte.dev/e/effect_update_depth_exceeded")}function pa(e){throw new Error("https://svelte.dev/e/props_invalid_value")}function _a(){throw new Error("https://svelte.dev/e/state_descriptors_fixed")}function ha(){throw new Error("https://svelte.dev/e/state_prototype_fixed")}function ba(){throw new Error("https://svelte.dev/e/state_unsafe_mutation")}function ma(){throw new Error("https://svelte.dev/e/svelte_boundary_reset_onerror")}const ga=1,xa=2,an=4,ya=8,wa=16,ka=1,Ta=4,Ea=8,Sa=16,$a=1,Aa=2,de=Symbol(),sn="http://www.w3.org/1999/xhtml";function Ca(){console.warn("https://svelte.dev/e/svelte_boundary_reset_noop")}function on(e){return e===this.v}function Da(e,t){return e!=e?t==t:e!==t||e!==null&&typeof e=="object"||typeof e=="function"}function ln(e){return!Da(e,this.v)}let Ia=!1,Be=null;function Lt(e){Be=e}function Wt(e,t=!1,r){Be={p:Be,i:!1,c:null,e:null,s:e,x:null,l:null}}function Yt(e){var t=Be,r=t.e;if(r!==null){t.e=null;for(var n of r)Cn(n)}return t.i=!0,Be=t.p,{}}function fn(){return!0}let St=[];function un(){var e=St;St=[],ra(e)}function at(e){if(St.length===0&&!Qt){var t=St;queueMicrotask(()=>{t===St&&un()})}St.push(e)}function Ma(){for(;St.length>0;)un()}function dn(e){var t=U;if(t===null)return P.f|=ht,e;if((t.f&qt)===0&&(t.f&nr)===0)throw e;ct(e,t)}function ct(e,t){for(;t!==null;){if((t.f&Tr)!==0){if((t.f&qt)===0)throw e;try{t.b.error(e);return}catch(r){e=r}}t=t.parent}throw e}const Oa=-7169;function re(e,t){e.f=e.f&Oa|t}function Nr(e){(e.f&Le)!==0||e.deps===null?re(e,fe):re(e,We)}function cn(e){if(e!==null)for(const t of e)(t.f&ce)===0||(t.f&It)===0||(t.f^=It,cn(t.deps))}function vn(e,t,r){(e.f&ve)!==0?t.add(e):(e.f&We)!==0&&r.add(e),cn(e.deps),re(e,fe)}const or=new Set;let q=null,Sr=null,He=null,Te=[],_r=null,Qt=!1,jt=null;class bt{current=new Map;previous=new Map;#e=new Set;#o=new Set;#t=0;#i=0;#r=null;#s=new Set;#n=new Set;#a=new Map;is_fork=!1;#l=!1;#u(){return this.is_fork||this.#i>0}skip_effect(t){this.#a.has(t)||this.#a.set(t,{d:[],m:[]})}unskip_effect(t){var r=this.#a.get(t);if(r){this.#a.delete(t);for(var n of r.d)re(n,ve),qe(n);for(n of r.m)re(n,We),qe(n)}}process(t){Te=[],this.apply();var r=jt=[],n=[];for(const a of t)this.#f(a,r,n);if(jt=null,this.#u()){this.#d(n),this.#d(r);for(const[a,s]of this.#a)bn(a,s)}else{Sr=this,q=null;for(const a of this.#e)a(this);this.#e.clear(),this.#t===0&&this.#c(),Vr(n),Vr(r),this.#s.clear(),this.#n.clear(),Sr=null,this.#r?.resolve()}He=null}#f(t,r,n){t.f^=fe;for(var a=t.first;a!==null;){var s=a.f,l=(s&(Ye|Ot))!==0,u=l&&(s&fe)!==0,o=u||(s&Me)!==0||this.#a.has(a);if(!o&&a.fn!==null){l?a.f^=fe:(s&nr)!==0?r.push(a):sr(a)&&((s&xt)!==0&&this.#n.add(a),Ht(a));var f=a.first;if(f!==null){a=f;continue}}for(;a!==null;){var d=a.next;if(d!==null){a=d;break}a=a.parent}}}#d(t){for(var r=0;r<t.length;r+=1)vn(t[r],this.#s,this.#n)}capture(t,r){r!==de&&!this.previous.has(t)&&this.previous.set(t,r),(t.f&ht)===0&&(this.current.set(t,t.v),He?.set(t,t.v))}activate(){q=this,this.apply()}deactivate(){q===this&&(q=null,He=null)}flush(){if(Te.length>0)q=this,pn();else if(this.#t===0&&!this.is_fork){for(const t of this.#e)t(this);this.#e.clear(),this.#c(),this.#r?.resolve()}this.deactivate()}discard(){for(const t of this.#o)t(this);this.#o.clear()}#c(){if(or.size>1){this.previous.clear();var t=q,r=He,n=!0;for(const s of or){if(s===this){n=!1;continue}const l=[];for(const[o,f]of this.current){if(s.current.has(o))if(n&&f!==s.current.get(o))s.current.set(o,f);else continue;l.push(o)}if(l.length===0)continue;const u=[...s.current.keys()].filter(o=>!this.current.has(o));Line truncated
|
|
||||||
${i(b)?"border-accent bg-accent/5":"border-border hover:border-border-hover bg-bg-primary/20"}
|
|
||||||
${i(n)==="uploading"?"opacity-50 pointer-events-none":""}`),Or(Q,"accept",i(d)==="bundle"?".bundle":".bin"),O(K,i(d)==="frontend"?"🎨":"⚙️")}),Dt("dragover",B,H=>{H.preventDefault(),c(b,!0)}),Dt("dragleave",B,()=>c(b,!1)),Dt("drop",B,g),z("change",Q,x),y(be,X)};N(ot,be=>{i(n)==="success"?be(Oe):be(Pt,!1)})}R(be=>{O(ie,`OTA Upgrade (${i(d)==="frontend"?"UI":"Firmware"})`),O(Ze,be)},[()=>i(I)()]),y(Y,M)};N(j,Y=>{i(f)&&Y(J)})}R(()=>{Se(Z,1,`text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
|
|
||||||
${i(f)&&i(d)==="frontend"?"bg-accent text-white":"bg-border text-text-secondary hover:text-text-primary"}`),Se(pe,1,`text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
|
|
||||||
${i(f)&&i(d)==="firmware"?"bg-accent text-white":"bg-border text-text-secondary hover:text-text-primary"}`),Se(we,1,`text-[10px] font-bold uppercase tracking-tight px-2 py-1 rounded transition-colors
|
|
||||||
${i(f)&&i(d)==="bundle"?"bg-accent text-white":"bg-border text-text-secondary hover:text-text-primary"}`),O(le,`Slot: ${i(l).active_partition??""} `),O(ae,i(u).firmware),O($,`Active: ${i(l).active_slot===0?"ota_0":"ota_1"}`)}),z("click",Z,()=>E("frontend")),z("click",pe,()=>E("firmware")),z("click",we,()=>E("bundle")),y(C,w)};N(V,C=>{C(te)})}y(e,L),Yt()}ir(["click","change"]);var ei=k('<span class="sidebar-title svelte-1nhzsi7">Menu</span>'),ti=k('<span class="nav-label svelte-1nhzsi7"> </span>'),ri=k('<button><span class="nav-icon svelte-1nhzsi7"> </span> <!></button>'),ni=k('<aside><div class="sidebar-header svelte-1nhzsi7"><!> <button><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="svelte-1nhzsi7"><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></div> <nav class="sidebar-nav svelte-1nhzsi7"></nav></aside>');function ai(e,t){Wt(t,!0);let r=_t(t,"currentView",3,"dashboard"),n=_t(t,"onNavigate",3,()=>{}),a=_t(t,"isOpen",3,!1),s=_t(t,"onToggle",3,null),l=A(!1);zt(()=>{typeof window<"u"&&window.innerWidth<=1024&&window.innerWidth>768&&c(l,!0)});const u=[{id:"dashboard",label:"Dashboard",icon:"🏠"},{id:"tasks",label:"Tasks",icon:"📋"},{id:"users",label:"Users",icon:"👥"}];var o=ni(),f=v(o),d=v(f);{var b=g=>{var S=ei();y(g,S)};N(d,g=>{i(l)||g(b)})}var _=h(d,2),x=h(f,2);pt(x,21,()=>u,vt,(g,S)=>{var m=ri(),E=v(m),I=v(E),L=h(E,2);{var V=te=>{var C=ti(),w=v(C);R(()=>O(w,i(S).label)),y(te,C)};N(L,te=>{i(l)||te(V)})}R(()=>{Se(m,1,`nav-item ${r()===i(S).id?"active":""}`,"svelte-1nhzsi7"),Or(m,"title",i(S).label),O(I,i(S).icon)}),z("click",m,()=>n()(i(S).id)),y(g,m)}),R(()=>{Se(o,1,`sidebar ${i(l)?"collapsed":""} ${a()?"mobile-open":""}`,"svelte-1nhzsi7"),Se(_,1,`collapse-btn ${s()?"mobile-only":""}`,"svelte-1nhzsi7"),Or(_,"title",i(l)?"Expand":"Collapse")}),z("click",_,()=>s()?s()():c(l,!i(l))),y(e,o),Yt()}ir(["click"]);var si=k('<div role="button" tabindex="0"><span class="user-name"> </span></div>'),ii=k('<div class="user-bar svelte-mekxsd"><div class="user-chips svelte-mekxsd"></div></div>'),oi=k('<div class="modal-overlay svelte-mekxsd"><form class="add-user-modal svelte-mekxsd"><h3 class="text-lg font-semibold">Add User</h3> <input type="text" placeholder="Name..." class="modal-input svelte-mekxsd"/> <div class="modal-actions svelte-mekxsd"><button type="button" class="btn-secondary svelte-mekxsd">Cancel</button> <button type="submit" class="btn-primary svelte-mekxsd">Add User</button></div></form></div>'),li=k('<form class="edit-row svelte-mekxsd"><input type="text" class="edit-input svelte-mekxsd"/> <div class="edit-actions svelte-mekxsd"><button type="submit" class="save-btn svelte-mekxsd" title="Save">✓</button> <button type="button" class="cancel-btn svelte-mekxsd" title="Cancel">✕</button></div></form>'),fi=k('<button class="action-btn danger svelte-mekxsd">Delete!</button> <button class="action-btn svelte-mekxsd">Cancel</button>',1),ui=k('<button class="action-btn svelte-mekxsd" title="Delete">🗑️</button>'),di=k('<div class="user-info svelte-mekxsd"><span class="user-name-large svelte-mekxsd"> </span> <span class="user-id svelte-mekxsd"> </span></div> <div class="user-actions svelte-mekxsd"><button class="action-btn svelte-mekxsd" title="Rename">✏️</button> <!></div>',1),ci=k('<div class="user-item svelte-mekxsd"><!></div>'),vi=k('<div class="empty-state svelte-mekxsd">No users found. Create one to get started!</div>'),pi=k('<header class="manager-header svelte-mekxsd"><h1 class="text-xl font-bold text-text-primary">User Management</h1> <button class="add-user-btn-large svelte-mekxsd">+ New User</button></header> <!> <div class="user-list svelte-mekxsd"></div>',1),_i=k('<div class="user-error svelte-mekxsd"> </div>'),hi=k("<div><!> <!></div>");function Kn(e,t){Wt(t,!0);let r=_t(t,"selectedUserId",15,null),n=_t(t,"onUsersChanged",3,()=>{}),a=_t(t,"mode",3,"selector"),s=A(Ie([])),l=A(""),u=A(!1),o=A(""),f=A(null),d=A(null),b=A("");async function _(){try{c(s,await Fs(),!0),i(s).length>0&&!r()&&a()==="selector"&&r(i(s)[0].id),r()&&!i(s).find(w=>w.id===r())&&r(i(s).length>0?i(s)[0].id:null),c(o,"")}catch(w){c(o,w.message,!0)}}async function x(){if(i(l).trim())try{const w=await Ns(i(l).trim());c(l,""),c(u,!1),await _(),a()==="selector"&&r(w.id),n()()}catch(w){c(o,w.message,!0)}}async function g(w){try{await Rs(w),c(f,null),await _(),n()()}catch(F){c(o,F.message,!0)}}function S(w){c(d,w.id,!0),c(b,w.name,!0)}async function m(){if(i(b).trim())try{await Us(i(d),i(b).trim()),c(d,null),await _(),n()()}catch(w){c(o,w.message,!0)}}zt(()=>{_()});var E=hi(),I=v(E);{var L=w=>{var F=ii(),ue=v(F);pt(ue,21,()=>i(s),vt,(Z,pe)=>{var we=si(),Je=v(we),_e=v(Je);R(()=>{Se(we,1,`user-chip ${r()===i(pe).id?"selected":""}`,"svelte-mekxsd"),O(_e,i(pe).name)}),z("click",we,()=>r(i(pe).id)),z("keydown",we,T=>{T.key==="Enter"&&r(i(pe).id)}),y(Z,Line truncated
|
|
||||||
bg-danger/10 text-danger border border-danger/20
|
|
||||||
hover:bg-danger/20 hover:border-danger/30
|
|
||||||
disabled:opacity-40 disabled:cursor-not-allowed">Reboot</button></div></div> <!></div></div>`,1),Ki=k('<div class="bg-bg-card border border-border rounded-xl p-6 shadow-xl"><!></div>'),Gi=k('<div class="bg-bg-card border border-border rounded-xl p-8 shadow-xl"><!></div>'),Ji=k('<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 backdrop-blur-sm"><div class="bg-bg-card border border-border rounded-xl p-6 max-w-sm w-full space-y-4 shadow-2xl"><h3 class="text-lg font-semibold text-text-primary">Confirm Reboot</h3> <p class="text-sm text-text-secondary">Are you sure you want to reboot the ESP32? The device will be temporarily unavailable.</p> <div class="flex gap-3 justify-end"><button class="px-4 py-2 text-sm rounded-lg bg-border/30 text-text-secondary hover:bg-border/50 transition-colors">Cancel</button> <button class="px-4 py-2 text-sm font-medium rounded-lg bg-danger text-white hover:bg-danger-hover transition-colors shadow-lg shadow-danger/20">Reboot Now</button></div></div></div>'),Zi=k('<div class="app-layout svelte-1n46o8q"><!> <!> <main class="main-content svelte-1n46o8q"><header class="mobile-header svelte-1n46o8q"><button class="hamburger svelte-1n46o8q"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="svelte-1n46o8q"><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 svelte-1n46o8q">Calendink</span> <div style="width: 40px;"></div></header> <div class="w-full max-w-6xl mx-auto space-y-8"><div class="text-center"><h1 class="text-2xl font-bold text-accent">Calendink Provider 📅📅🚀🚀⚡✨</h1> <p class="text-text-secondary text-sm"></p> <div class="flex justify-center mt-4"><!></div></div> <!> <!></div></main></div>');function Xi(e,t){Wt(t,!0);let r=A("loading"),n=A(""),a=A(!1),s=A(!1),l=A("dashboard"),u=A(!1),o=A(Ie({chip:"—",freeHeap:0,uptime:0,firmware:"—",connection:"—"})),f=A(Ie({partitions:[],active_partition:"—",running_firmware_label:"—"})),d=A(Ie({users:[]}));function b(p){const $=Math.floor(p/86400),j=Math.floor(p%86400/3600),J=Math.floor(p%3600/60),Y=p%60,M=[];return $>0&&M.push(`${$}d`),j>0&&M.push(`${j}h`),J>0&&M.push(`${J}m`),M.push(`${Y}s`),M.join(" ")}function _(p){return p>=1048576?`${(p/1048576).toFixed(1)} MB`:p>=1024?`${(p/1024).toFixed(1)} KB`:`${p} B`}function x(p){const $=Date.now()/1e3,j=p-$,J=Math.abs(j);if(J<3600){const M=Math.round(J/60);return j<0?`${M}m ago`:`in ${M}m`}if(J<86400){const M=Math.round(J/3600);return j<0?`${M}h ago`:`in ${M}h`}const Y=Math.round(J/86400);return j<0?`${Y}d ago`:`in ${Y}d`}function g(p){return p<Date.now()/1e3}async function S(){try{const[p,$,j]=await Promise.all([Pr(),Yn(),js().catch(()=>({users:[]}))]);c(o,p,!0),c(f,$,!0),c(d,j,!0),c(r,"ok"),c(n,"")}catch(p){i(s)||(c(r,"error"),c(n,p.message||"Connection failed",!0))}}async function m(){c(a,!1),c(r,"rebooting"),c(s,!0);try{await Is()}catch{}}zt(()=>{S();const p=setInterval(S,5e3);return()=>clearInterval(p)}),zt(()=>{if(i(s)){const p=setInterval(async()=>{try{await Pr()&&(console.log("Device back online! Refreshing UI..."),window.location.reload())}catch{console.log("Waiting for device...")}},2e3);return()=>clearInterval(p)}});const E=mn(()=>[{label:"Chip",value:i(o).chip,icon:"🔧"},{label:"Free Heap",value:_(i(o).freeHeap),icon:"💾"},{label:"Uptime",value:b(i(o).uptime),icon:"⏱️"},{label:"Firmware",value:i(o).firmware,icon:"📦"},{label:"Connection",value:i(o).connection,icon:"🌐"}]);var I=Zi(),L=v(I);ai(L,{get currentView(){return i(l)},get isOpen(){return i(u)},onNavigate:p=>{c(l,p,!0),c(u,!1)},onToggle:()=>c(u,!i(u))});var V=h(L,2);{var te=p=>{var $=Di();z("click",$,()=>c(u,!1)),y(p,$)};N(V,p=>{i(u)&&p(te)})}var C=h(V,2),w=v(C),F=v(w),ue=h(w,2),Z=v(ue),pe=h(v(Z),2);pe.textContent="ESP32-S3 System Dashboard v0.1.18";var we=h(pe,2),Je=v(we);{var _e=p=>{var $=Ii();y(p,$)},T=p=>{var $=Mi();y(p,$)},D=p=>{var $=Oi();y(p,$)},W=p=>{var $=Pi(),j=h(v($),2),J=v(j);R(()=>O(J,`Offline — ${i(n)??""}`)),y(p,$)};N(Je,p=>{i(r)==="loading"?p(_e):i(r)==="ok"?p(T,1):i(r)==="rebooting"?p(D,2):p(W,!1)})}var le=h(Z,2);{var ge=p=>{var $=Yi(),j=Ue($);{var J=X=>{var B=Li(),Q=h(v(B),2);pt(Q,21,()=>i(d).users,vt,(Pe,K)=>{var Xe=Ui(),Qe=v(Xe),lt=v(Qe),Ke=h(Qe,2);{var kt=Fe=>{var xe=Fi();y(Fe,xe)},Tt=Fe=>{var xe=Ri();pt(xe,21,()=>i(K).tasks,vt,(H,ee)=>{var me=Ni(),et=v(me),Kt=v(et),Ft=h(et,2),Gt=v(Ft);R((mr,gr)=>{Se(et,1,`text-[10px] mt-0.5 ${mr??""} font-mono whitespace-nowrap`),O(Kt,gr),O(Gt,i(ee).title)},[()=>g(i(ee).due_date)?"text-danger":"text-text-secondary",()=>x(i(ee).due_date)]),y(H,me)}),y(Fe,xe)};N(Ke,Fe=>{i(K).tasks.length===0?Fe(kt):Fe(Tt,!1)})}R(()=>O(lt,i(K).name)),y(Pe,Xe)}),y(X,B)};N(j,X=>{i(d).users.length>0&&X(J)})}var Y=h(j,2),M=v(Y),se=v(M),ke=h(v(se),2);pt(ke,21,()=>i(E),vt,(X,B)=>{var Q=Bi(),Pe=v(Q),K=v(Pe),Xe=v(K),Qe=h(K,2),lt=v(Qe),KLine truncated
|
|
||||||
<style rel="stylesheet" crossorigin>@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-divide-x-reverse:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-xl:36rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-wider:.05em;--leading-tight:1.25;--radius-lg:.5rem;--radius-xl:.75rem;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-bg-primary:#0f172a;--color-bg-card:#1e293b;--color-bg-card-hover:#263548;--color-text-primary:#f1f5f9;--color-text-secondary:#94a3b8;--color-accent:#38bdf8;--color-border:#334155;--color-success:#22c55e;--color-danger:#ef4444;--color-danger-hover:#f87171}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacityLine truncated
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
@@ -298,9 +298,11 @@ internal httpd_handle_t start_webserver(void)
|
|||||||
httpd_register_uri_handler(server, &api_tasks_update_uri);
|
httpd_register_uri_handler(server, &api_tasks_update_uri);
|
||||||
httpd_register_uri_handler(server, &api_tasks_delete_uri);
|
httpd_register_uri_handler(server, &api_tasks_delete_uri);
|
||||||
|
|
||||||
// Populate dummy data for development
|
// Populate dummy data for development (debug builds only)
|
||||||
|
#ifndef NDEBUG
|
||||||
seed_users();
|
seed_users();
|
||||||
seed_tasks();
|
seed_tasks();
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
|
#ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES
|
||||||
// Register static file handler last as a catch-all wildcard if deployed
|
// Register static file handler last as a catch-all wildcard if deployed
|
||||||
|
|||||||
+23
-21
@@ -26,6 +26,8 @@
|
|||||||
#include "mdns_service.cpp"
|
#include "mdns_service.cpp"
|
||||||
// clang-format on
|
// clang-format on
|
||||||
|
|
||||||
|
internal const char *kTagMain = "MAIN";
|
||||||
|
|
||||||
// Global Application State Definitions
|
// Global Application State Definitions
|
||||||
bool g_Ethernet_Initialized = false;
|
bool g_Ethernet_Initialized = false;
|
||||||
bool g_Wifi_Initialized = false;
|
bool g_Wifi_Initialized = false;
|
||||||
@@ -35,9 +37,8 @@ constexpr bool kBlockUntilEthernetEstablished = false;
|
|||||||
|
|
||||||
extern "C" void app_main()
|
extern "C" void app_main()
|
||||||
{
|
{
|
||||||
printf("Hello, Calendink OTA! [V1.1]\n");
|
ESP_LOGI(kTagMain, "Hello, Calendink OTA! [V0.1.1]");
|
||||||
|
ESP_LOGI(kTagMain, "PSRAM size: %d bytes", esp_psram_get_size());
|
||||||
printf("PSRAM size: %d bytes\n", esp_psram_get_size());
|
|
||||||
|
|
||||||
httpd_handle_t web_server = NULL;
|
httpd_handle_t web_server = NULL;
|
||||||
|
|
||||||
@@ -56,22 +57,22 @@ extern "C" void app_main()
|
|||||||
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
|
err = nvs_get_u8(my_handle, "www_part", &g_Active_WWW_Partition);
|
||||||
if (err == ESP_OK)
|
if (err == ESP_OK)
|
||||||
{
|
{
|
||||||
printf("NVS: Found active www partition: %d\n", g_Active_WWW_Partition);
|
ESP_LOGI(kTagMain, "NVS: Found active www partition: %d",
|
||||||
|
g_Active_WWW_Partition);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err == ESP_ERR_NVS_NOT_FOUND)
|
if (err == ESP_ERR_NVS_NOT_FOUND)
|
||||||
{
|
{
|
||||||
// First boot (no NVS key yet): default to www_0
|
// First boot (no NVS key yet): default to www_0
|
||||||
// This ensures that after a fresh USB flash (which only writes www_0),
|
ESP_LOGI(kTagMain, "No www_part in NVS, defaulting to 0.");
|
||||||
// we start from the correct partition.
|
|
||||||
printf("No www_part in NVS, defaulting to 0.\n");
|
|
||||||
g_Active_WWW_Partition = 0;
|
g_Active_WWW_Partition = 0;
|
||||||
nvs_set_u8(my_handle, "www_part", 0);
|
nvs_set_u8(my_handle, "www_part", 0);
|
||||||
nvs_commit(my_handle);
|
nvs_commit(my_handle);
|
||||||
}
|
}
|
||||||
else if (err != ESP_OK)
|
else if (err != ESP_OK)
|
||||||
{
|
{
|
||||||
printf("Error reading www_part from NVS: %s\n", esp_err_to_name(err));
|
ESP_LOGE(kTagMain, "Error reading www_part from NVS: %s",
|
||||||
|
esp_err_to_name(err));
|
||||||
g_Active_WWW_Partition = 0;
|
g_Active_WWW_Partition = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ extern "C" void app_main()
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
printf("Error opening NVS handle!\n");
|
ESP_LOGE(kTagMain, "Error opening NVS handle!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect if this is the first boot after a new flash (Firmware or Frontend)
|
// Detect if this is the first boot after a new flash (Firmware or Frontend)
|
||||||
@@ -98,8 +99,8 @@ extern "C" void app_main()
|
|||||||
ESP_OK ||
|
ESP_OK ||
|
||||||
strcmp(last_time, current_time) != 0)
|
strcmp(last_time, current_time) != 0)
|
||||||
{
|
{
|
||||||
printf("New firmware detected! (Last: %s, Current: %s)\n",
|
ESP_LOGI(kTagMain, "New firmware detected! (Last: %s, Current: %s)",
|
||||||
last_time[0] ? last_time : "None", current_time);
|
last_time[0] ? last_time : "None", current_time);
|
||||||
is_new_flash = true;
|
is_new_flash = true;
|
||||||
nvs_set_str(my_handle, "last_fw_time", current_time);
|
nvs_set_str(my_handle, "last_fw_time", current_time);
|
||||||
}
|
}
|
||||||
@@ -118,7 +119,7 @@ extern "C" void app_main()
|
|||||||
ESP_OK ||
|
ESP_OK ||
|
||||||
memcmp(last_sha, current_sha, 32) != 0)
|
memcmp(last_sha, current_sha, 32) != 0)
|
||||||
{
|
{
|
||||||
printf("New frontend partition detected via SHA256!\n");
|
ESP_LOGI(kTagMain, "New frontend partition detected via SHA256!");
|
||||||
is_new_flash = true;
|
is_new_flash = true;
|
||||||
nvs_set_blob(my_handle, "www0_sha", current_sha, 32);
|
nvs_set_blob(my_handle, "www0_sha", current_sha, 32);
|
||||||
}
|
}
|
||||||
@@ -141,8 +142,9 @@ extern "C" void app_main()
|
|||||||
{
|
{
|
||||||
if (is_new_flash && g_Active_WWW_Partition != 0)
|
if (is_new_flash && g_Active_WWW_Partition != 0)
|
||||||
{
|
{
|
||||||
printf("FACTORY APP + NEW FLASH: Overriding www_part to 0 (was %d)\n",
|
ESP_LOGW(kTagMain,
|
||||||
g_Active_WWW_Partition);
|
"FACTORY APP + NEW FLASH: Overriding www_part to 0 (was %d)",
|
||||||
|
g_Active_WWW_Partition);
|
||||||
g_Active_WWW_Partition = 0;
|
g_Active_WWW_Partition = 0;
|
||||||
|
|
||||||
// Persist the override
|
// Persist the override
|
||||||
@@ -181,7 +183,7 @@ extern "C" void app_main()
|
|||||||
|
|
||||||
if (result == ESP_ERR_INVALID_STATE)
|
if (result == ESP_ERR_INVALID_STATE)
|
||||||
{
|
{
|
||||||
printf("Ethernet cable not plugged in, skipping retries.\n");
|
ESP_LOGW(kTagMain, "Ethernet cable not plugged in, skipping retries.");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +200,7 @@ extern "C" void app_main()
|
|||||||
|
|
||||||
if (result != ESP_OK)
|
if (result != ESP_OK)
|
||||||
{
|
{
|
||||||
printf("Ethernet failed, trying wifi\n");
|
ESP_LOGW(kTagMain, "Ethernet failed, trying wifi");
|
||||||
disconnect_ethernet();
|
disconnect_ethernet();
|
||||||
g_Ethernet_Initialized = false;
|
g_Ethernet_Initialized = false;
|
||||||
|
|
||||||
@@ -235,20 +237,20 @@ extern "C" void app_main()
|
|||||||
|
|
||||||
if (result != ESP_OK)
|
if (result != ESP_OK)
|
||||||
{
|
{
|
||||||
printf("Wifi failed.\n");
|
ESP_LOGE(kTagMain, "Wifi failed.");
|
||||||
goto shutdown;
|
goto shutdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
set_led_status(led_status::ReadyWifi);
|
set_led_status(led_status::ReadyWifi);
|
||||||
printf("Will use Wifi!\n");
|
ESP_LOGI(kTagMain, "Will use Wifi!");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
set_led_status(led_status::ReadyEthernet);
|
set_led_status(led_status::ReadyEthernet);
|
||||||
printf("Will use Ethernet!\n");
|
ESP_LOGI(kTagMain, "Will use Ethernet!");
|
||||||
}
|
}
|
||||||
|
|
||||||
printf("Connected! IP acquired.\n");
|
ESP_LOGI(kTagMain, "Connected! IP acquired.");
|
||||||
|
|
||||||
// Start the webserver
|
// Start the webserver
|
||||||
web_server = start_webserver();
|
web_server = start_webserver();
|
||||||
@@ -274,7 +276,7 @@ extern "C" void app_main()
|
|||||||
}
|
}
|
||||||
|
|
||||||
shutdown:
|
shutdown:
|
||||||
printf("Shutting down.\n");
|
ESP_LOGE(kTagMain, "Shutting down.");
|
||||||
|
|
||||||
if (web_server)
|
if (web_server)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
#include "mdns.h"
|
#include "mdns.h"
|
||||||
#include "sdkconfig.h"
|
#include "sdkconfig.h"
|
||||||
|
|
||||||
static const char *kLogMDNS = "MDNS";
|
#include "types.hpp"
|
||||||
|
|
||||||
|
internal const char *kTagMDNS = "MDNS";
|
||||||
|
|
||||||
void start_mdns()
|
void start_mdns()
|
||||||
{
|
{
|
||||||
@@ -10,14 +12,14 @@ void start_mdns()
|
|||||||
esp_err_t err = mdns_init();
|
esp_err_t err = mdns_init();
|
||||||
if (err != ESP_OK)
|
if (err != ESP_OK)
|
||||||
{
|
{
|
||||||
ESP_LOGE(kLogMDNS, "mDNS Init failed: %d", err);
|
ESP_LOGE(kTagMDNS, "mDNS Init failed: %d", err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set mDNS hostname (from Kconfig)
|
// Set mDNS hostname (from Kconfig)
|
||||||
const char *hostname = CONFIG_CALENDINK_MDNS_HOSTNAME;
|
const char *hostname = CONFIG_CALENDINK_MDNS_HOSTNAME;
|
||||||
mdns_hostname_set(hostname);
|
mdns_hostname_set(hostname);
|
||||||
ESP_LOGI(kLogMDNS, "mDNS Hostname set to: [%s.local]", hostname);
|
ESP_LOGI(kTagMDNS, "mDNS Hostname set to: [%s.local]", hostname);
|
||||||
|
|
||||||
// Set mDNS instance name
|
// Set mDNS instance name
|
||||||
mdns_instance_name_set("Calendink Provider");
|
mdns_instance_name_set("Calendink Provider");
|
||||||
@@ -26,9 +28,8 @@ void start_mdns()
|
|||||||
err = mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0);
|
err = mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0);
|
||||||
if (err != ESP_OK)
|
if (err != ESP_OK)
|
||||||
{
|
{
|
||||||
ESP_LOGE(kLogMDNS, "mDNS Service add failed: %d", err);
|
ESP_LOGE(kTagMDNS, "mDNS Service add failed: %d", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
printf("MDNS: Service initialized with hostname [%s.local]\n", hostname);
|
ESP_LOGI(kTagMDNS, "mDNS Service initialized with hostname [%s.local]", hostname);
|
||||||
ESP_LOGI(kLogMDNS, "mDNS Service initialized");
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user