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