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>
|
<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>
|
|
||||||
|
|||||||
@@ -75,362 +75,134 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load initial data on mount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
fetchUsers();
|
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
|
||||||
|
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}
|
onclick={() => selectedUserId = user.id}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }}
|
onkeydown={(e) => { if (e.key === 'Enter') selectedUserId = user.id; }}
|
||||||
>
|
>
|
||||||
<span class="user-name">{user.name}</span>
|
<span>{user.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Manager Mode -->
|
<!-- 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>
|
<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
|
+ New User
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</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)]"
|
||||||
|
onsubmit={(e) => { e.preventDefault(); handleAddUser(); }}
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-semibold text-text-primary">Add User</h3>
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newUserName}
|
bind:value={newUserName}
|
||||||
placeholder="Name..."
|
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
|
autofocus
|
||||||
/>
|
/>
|
||||||
<div class="modal-actions">
|
<div class="flex gap-3 justify-end">
|
||||||
<button type="button" class="btn-secondary" onclick={() => { showAddForm = false; newUserName = ''; }}>Cancel</button>
|
<button
|
||||||
<button type="submit" class="btn-primary" disabled={!newUserName.trim()}>Add User</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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"
|
||||||
|
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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="user-info">
|
<div class="flex flex-col gap-1">
|
||||||
<span class="user-name-large">{user.name}</span>
|
<span class="text-base font-semibold text-text-primary">{user.name}</span>
|
||||||
<span class="user-id">ID: {user.id}</span>
|
<span class="text-[11px] text-text-secondary font-mono uppercase tracking-[0.05em]">ID: {user.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-actions">
|
<div class="flex gap-2">
|
||||||
<button class="action-btn" onclick={() => startEditing(user)} title="Rename">✏️</button>
|
<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}
|
{#if confirmDeleteId === user.id}
|
||||||
<button class="action-btn danger" onclick={() => handleRemoveUser(user.id)}>Delete!</button>
|
<button
|
||||||
<button class="action-btn" onclick={() => confirmDeleteId = null}>Cancel</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}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
|
||||||
|
|||||||
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_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
|
||||||
|
|||||||
@@ -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,7 +99,7 @@ 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,7 +142,8 @@ 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,
|
||||||
|
"FACTORY APP + NEW FLASH: Overriding www_part to 0 (was %d)",
|
||||||
g_Active_WWW_Partition);
|
g_Active_WWW_Partition);
|
||||||
g_Active_WWW_Partition = 0;
|
g_Active_WWW_Partition = 0;
|
||||||
|
|
||||||
@@ -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