ui updates
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover" />
|
||||
<meta name="description" content="Mobile-first task management with offline support" />
|
||||
<meta name="theme-color" content="#4f46e5" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
|
||||
@@ -25,6 +25,9 @@ export const tasks = {
|
||||
if (filters.tags) {
|
||||
filters.tags.forEach(tag => params.append('tag', tag));
|
||||
}
|
||||
if (filters.excludeTags) {
|
||||
filters.excludeTags.forEach(tag => params.append('exclude_tag', tag));
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
return apiRequest(`/tasks${query ? `?${query}` : ''}`);
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
* @property {string} [project]
|
||||
* @property {string} [priority]
|
||||
* @property {string[]} [tags]
|
||||
* @property {string[]} [excludeTags]
|
||||
*/
|
||||
|
||||
export {};
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
<script>
|
||||
import { recentFilters, setFilter, removeRecent } from '$lib/stores/filters.js';
|
||||
|
||||
/** @type {() => void} */
|
||||
export let onClose;
|
||||
|
||||
let filterInput = '';
|
||||
|
||||
/** @type {HTMLDialogElement|null} */
|
||||
let dialogEl = null;
|
||||
|
||||
/** @type {HTMLInputElement|null} */
|
||||
let inputEl = null;
|
||||
|
||||
export function open() {
|
||||
filterInput = '';
|
||||
dialogEl?.showModal();
|
||||
// Focus after dialog animation
|
||||
requestAnimationFrame(() => inputEl?.focus());
|
||||
}
|
||||
|
||||
function apply() {
|
||||
const trimmed = filterInput.trim();
|
||||
if (trimmed) {
|
||||
setFilter(trimmed);
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
function close() {
|
||||
dialogEl?.close();
|
||||
onClose();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filter
|
||||
*/
|
||||
function applyRecent(filter) {
|
||||
setFilter(filter);
|
||||
close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} e
|
||||
* @param {string} filter
|
||||
*/
|
||||
function handleRemoveRecent(e, filter) {
|
||||
e.stopPropagation();
|
||||
removeRecent(filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
apply();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} e
|
||||
*/
|
||||
function handleBackdropClick(e) {
|
||||
if (e.target === dialogEl) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
class="filter-modal"
|
||||
on:click={handleBackdropClick}
|
||||
>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Filter</h3>
|
||||
<button class="close-btn" on:click={close} type="button" aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" width="20" height="20">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={filterInput}
|
||||
on:keydown={handleKeydown}
|
||||
type="text"
|
||||
placeholder="e.g. +grocer project:home"
|
||||
class="filter-input"
|
||||
/>
|
||||
<button
|
||||
class="apply-btn"
|
||||
on:click={apply}
|
||||
disabled={!filterInput.trim()}
|
||||
type="button"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $recentFilters.length > 0}
|
||||
<div class="recents">
|
||||
<div class="recents-label">Recent</div>
|
||||
{#each $recentFilters as recent}
|
||||
<button
|
||||
class="recent-row"
|
||||
on:click={() => applyRecent(recent)}
|
||||
type="button"
|
||||
>
|
||||
<span class="recent-text">{recent}</span>
|
||||
<span
|
||||
class="recent-remove"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={(e) => handleRemoveRecent(e, recent)}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleRemoveRecent(e, recent)}
|
||||
aria-label="Remove {recent} from recents"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" width="14" height="14">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.filter-modal {
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
max-width: min(400px, calc(100vw - 2rem));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-modal::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--focus-ring);
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
min-width: unset;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.apply-btn:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.apply-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.recents {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.recents-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.recent-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-height: 40px;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.recent-row:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.recent-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.recent-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 0.25rem;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recent-remove:hover {
|
||||
color: var(--color-danger);
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script>
|
||||
import { activeFilter, setFilter } from '$lib/stores/filters.js';
|
||||
|
||||
/**
|
||||
* Remove a single token from the active filter string.
|
||||
* If it's the last token, clear the entire filter.
|
||||
* @param {string} token
|
||||
*/
|
||||
function removeToken(token) {
|
||||
if (!$activeFilter) return;
|
||||
const tokens = $activeFilter.trim().split(/\s+/);
|
||||
const remaining = tokens.filter(t => t !== token);
|
||||
if (remaining.length === 0) {
|
||||
activeFilter.set(null);
|
||||
} else {
|
||||
setFilter(remaining.join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} token */
|
||||
function tokenType(token) {
|
||||
if (token.startsWith('+')) return 'tag';
|
||||
if (token.startsWith('-')) return 'exclude';
|
||||
if (token.includes(':')) return 'attr';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$: tokens = $activeFilter ? $activeFilter.trim().split(/\s+/) : [];
|
||||
</script>
|
||||
|
||||
{#if tokens.length > 0}
|
||||
<div class="filter-pills">
|
||||
{#each tokens as token}
|
||||
<button
|
||||
class="filter-pill {tokenType(token)}"
|
||||
type="button"
|
||||
on:mousedown|preventDefault={() => removeToken(token)}
|
||||
title="Remove filter: {token}"
|
||||
>
|
||||
<span class="pill-text">{token}</span>
|
||||
<svg class="pill-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" width="12" height="12">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.filter-pills {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 1px;
|
||||
align-items: stretch;
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0 0.5rem;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-mono);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
min-height: unset;
|
||||
min-width: unset;
|
||||
line-height: 1.3;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.filter-pill:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.filter-pill.tag {
|
||||
background-color: var(--color-tag-bg);
|
||||
color: var(--color-tag-text);
|
||||
}
|
||||
|
||||
.filter-pill.exclude {
|
||||
background-color: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high-text);
|
||||
}
|
||||
|
||||
.filter-pill.attr {
|
||||
background-color: var(--color-project-bg);
|
||||
color: var(--color-project-text);
|
||||
}
|
||||
|
||||
.filter-pill.unknown {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pill-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill-x {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.filter-pill:hover .pill-x {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,7 @@
|
||||
<script>
|
||||
import ReportPicker from './ReportPicker.svelte';
|
||||
import FilterModal from './FilterModal.svelte';
|
||||
import { activeFilter } from '$lib/stores/filters.js';
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
@@ -14,6 +16,9 @@
|
||||
/** @type {ReportPicker} */
|
||||
let picker;
|
||||
|
||||
/** @type {FilterModal} */
|
||||
let filterModal;
|
||||
|
||||
/** Map backend report names to display labels */
|
||||
const reportLabels = /** @type {Record<string, string>} */ ({
|
||||
list: 'Pending',
|
||||
@@ -30,26 +35,43 @@
|
||||
});
|
||||
|
||||
$: displayLabel = reportLabels[activeReport] || activeReport;
|
||||
$: hasActiveFilter = !!$activeFilter;
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<button
|
||||
class="report-btn"
|
||||
on:click={() => picker.toggle()}
|
||||
>
|
||||
<span class="report-label">{displayLabel}</span>
|
||||
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="header-left">
|
||||
<button
|
||||
class="report-btn"
|
||||
on:click={() => picker.toggle()}
|
||||
>
|
||||
<span class="report-label">{displayLabel}</span>
|
||||
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={hasActiveFilter}
|
||||
on:click={() => filterModal.open()}
|
||||
aria-label="Filter tasks"
|
||||
>
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
{#if hasActiveFilter}
|
||||
<span class="filter-dot"></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<a href="/settings" class="settings-btn" aria-label="Settings">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/settings" class="settings-btn" aria-label="Settings">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -59,6 +81,11 @@
|
||||
onSelect={onReportChange}
|
||||
/>
|
||||
|
||||
<FilterModal
|
||||
bind:this={filterModal}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
grid-area: header;
|
||||
@@ -67,7 +94,14 @@
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.report-btn {
|
||||
@@ -100,6 +134,40 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
min-width: unset;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.filter-dot {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script>
|
||||
import PropertyPills from './PropertyPills.svelte';
|
||||
import FilterPills from './FilterPills.svelte';
|
||||
import { activeFilter } from '$lib/stores/filters.js';
|
||||
import { mergeInputWithFilter } from '$lib/utils/filters.js';
|
||||
|
||||
/**
|
||||
* @type {(input: string) => Promise<void>}
|
||||
@@ -21,8 +24,12 @@
|
||||
async function handleSubmit() {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || loading) return;
|
||||
|
||||
// Merge user input with active filter, deduplicating tokens
|
||||
const merged = mergeInputWithFilter(trimmed, $activeFilter || '');
|
||||
|
||||
try {
|
||||
await onSubmit(trimmed);
|
||||
await onSubmit(merged);
|
||||
value = '';
|
||||
} catch {
|
||||
// Value preserved for retry
|
||||
@@ -78,16 +85,36 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current input value (for PropertyPills smart replace)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getInputValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the input value (for PropertyPills smart replace)
|
||||
* @param {string} newValue
|
||||
*/
|
||||
export function setInputValue(newValue) {
|
||||
value = newValue;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="input-bar">
|
||||
<PropertyPills visible={focused} onInsert={insertAtCursor} />
|
||||
<PropertyPills visible={focused} onInsert={insertAtCursor} inputValue={value} onInputChange={(v) => { value = v; }} />
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="input-row">
|
||||
<div class="input-row" class:focused>
|
||||
{#if $activeFilter}
|
||||
<FilterPills />
|
||||
<div class="separator"></div>
|
||||
{/if}
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value
|
||||
@@ -95,7 +122,7 @@
|
||||
on:focus={handleFocus}
|
||||
on:blur={handleBlur}
|
||||
type="text"
|
||||
placeholder="Add task... (e.g. Buy milk due:tomorrow priority:H)"
|
||||
placeholder={$activeFilter ? "Add task..." : "Add task... (e.g. Buy milk due:tomorrow priority:H)"}
|
||||
disabled={loading}
|
||||
class="input"
|
||||
/>
|
||||
@@ -121,25 +148,39 @@
|
||||
.input-bar {
|
||||
grid-area: input;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px));
|
||||
background-color: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--bg-secondary);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.input-row.focused {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--focus-ring);
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background-color: var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
background-color: var(--bg-secondary);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -150,8 +191,6 @@
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--focus-ring);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
@@ -163,7 +202,7 @@
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
border-radius: 0 calc(var(--border-radius) - 1px) calc(var(--border-radius) - 1px) 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
min-width: 44px;
|
||||
|
||||
@@ -1,21 +1,47 @@
|
||||
<script>
|
||||
import { removeTokenByPrefix } from '$lib/utils/filters.js';
|
||||
|
||||
/**
|
||||
* @type {(text: string) => void}
|
||||
*/
|
||||
export let onInsert;
|
||||
|
||||
/** Current input value for smart replace */
|
||||
export let inputValue = '';
|
||||
|
||||
/** Callback to update the input value when doing smart replace */
|
||||
export let onInputChange = /** @type {(value: string) => void} */ (() => {});
|
||||
|
||||
export let visible = false;
|
||||
|
||||
const pills = [
|
||||
{ label: "Due", text: "due:" },
|
||||
{ label: "Pri", text: "priority:" },
|
||||
{ label: "Project", text: "project:" },
|
||||
{ label: "Tag", text: "+" },
|
||||
{ label: "Recur", text: "recur:" },
|
||||
{ label: "Scheduled", text: "scheduled:" },
|
||||
{ label: "Wait", text: "wait:" },
|
||||
{ label: "Until", text: "until:" },
|
||||
{ label: "Due", text: "due:", isTag: false },
|
||||
{ label: "Pri", text: "priority:", isTag: false },
|
||||
{ label: "Project", text: "project:", isTag: false },
|
||||
{ label: "Tag", text: "+", isTag: true },
|
||||
{ label: "Recur", text: "recur:", isTag: false },
|
||||
{ label: "Scheduled", text: "scheduled:", isTag: false },
|
||||
{ label: "Wait", text: "wait:", isTag: false },
|
||||
{ label: "Until", text: "until:", isTag: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {{ text: string, isTag: boolean }} pill
|
||||
*/
|
||||
function handleInsert(pill) {
|
||||
// Tags are always additive — no smart replace
|
||||
if (!pill.isTag && inputValue) {
|
||||
const prefix = pill.text; // e.g. "due:"
|
||||
const cleaned = removeTokenByPrefix(inputValue, prefix);
|
||||
if (cleaned !== inputValue) {
|
||||
onInputChange(cleaned);
|
||||
// Let the DOM update, then insert
|
||||
requestAnimationFrame(() => onInsert(pill.text));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onInsert(pill.text);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
@@ -24,7 +50,7 @@
|
||||
<button
|
||||
class="pill"
|
||||
type="button"
|
||||
on:mousedown|preventDefault={() => onInsert(pill.text)}
|
||||
on:mousedown|preventDefault={() => handleInsert(pill)}
|
||||
>
|
||||
{pill.label}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { getItem, setItem } from '$lib/utils/storage.js';
|
||||
|
||||
const ACTIVE_KEY = 'opal_active_filter';
|
||||
const RECENT_KEY = 'opal_recent_filters';
|
||||
const MAX_RECENT = 8;
|
||||
|
||||
/**
|
||||
* Create a localStorage-backed writable store
|
||||
* @template T
|
||||
* @param {string} key
|
||||
* @param {T} fallback
|
||||
*/
|
||||
function persisted(key, fallback) {
|
||||
const initial = getItem(key) ?? fallback;
|
||||
const store = writable(/** @type {T} */ (initial));
|
||||
store.subscribe(value => setItem(key, value));
|
||||
return store;
|
||||
}
|
||||
|
||||
export const activeFilter = persisted(ACTIVE_KEY, /** @type {string|null} */ (null));
|
||||
export const recentFilters = persisted(RECENT_KEY, /** @type {string[]} */ ([]));
|
||||
|
||||
/**
|
||||
* Set the active filter and add it to recents
|
||||
* @param {string} str
|
||||
*/
|
||||
export function setFilter(str) {
|
||||
const trimmed = str.trim();
|
||||
if (!trimmed) {
|
||||
clearFilter();
|
||||
return;
|
||||
}
|
||||
activeFilter.set(trimmed);
|
||||
|
||||
recentFilters.update(recents => {
|
||||
const deduped = recents.filter(r => r !== trimmed);
|
||||
return [trimmed, ...deduped].slice(0, MAX_RECENT);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the active filter
|
||||
*/
|
||||
export function clearFilter() {
|
||||
activeFilter.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific entry from recents
|
||||
* @param {string} str
|
||||
*/
|
||||
export function removeRecent(str) {
|
||||
recentFilters.update(recents => recents.filter(r => r !== str));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Valid filter attribute keys (these actually work as query filters in the engine)
|
||||
*/
|
||||
const FILTER_ATTRS = new Set(['status', 'project', 'priority']);
|
||||
|
||||
/**
|
||||
* @typedef {Object} ParsedFilter
|
||||
* @property {string[]} tags
|
||||
* @property {string[]} excludeTags
|
||||
* @property {Record<string, string>} attributes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a filter string into structured tokens.
|
||||
* Recognizes +tag, -tag, and key:value for supported filter attributes.
|
||||
* Unknown tokens (like due:3d) are preserved as raw tokens for pass-through.
|
||||
* @param {string} str
|
||||
* @returns {ParsedFilter}
|
||||
*/
|
||||
export function parseFilterString(str) {
|
||||
/** @type {ParsedFilter} */
|
||||
const result = { tags: [], excludeTags: [], attributes: {} };
|
||||
if (!str || !str.trim()) return result;
|
||||
|
||||
const tokens = str.trim().split(/\s+/);
|
||||
for (const token of tokens) {
|
||||
if (token.startsWith('+') && token.length > 1) {
|
||||
result.tags.push(token.slice(1));
|
||||
} else if (token.startsWith('-') && token.length > 1 && !token.includes(':')) {
|
||||
result.excludeTags.push(token.slice(1));
|
||||
} else if (token.includes(':')) {
|
||||
const idx = token.indexOf(':');
|
||||
const key = token.slice(0, idx).toLowerCase();
|
||||
const value = token.slice(idx + 1);
|
||||
if (FILTER_ATTRS.has(key) && value) {
|
||||
result.attributes[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a parsed filter to TaskFilters for the API
|
||||
* @param {ParsedFilter} parsed
|
||||
* @returns {import('$lib/api/types.js').TaskFilters}
|
||||
*/
|
||||
export function filterToParams(parsed) {
|
||||
/** @type {import('$lib/api/types.js').TaskFilters} */
|
||||
const params = {};
|
||||
if (parsed.tags.length > 0) params.tags = parsed.tags;
|
||||
if (parsed.excludeTags.length > 0) params.excludeTags = parsed.excludeTags;
|
||||
if (parsed.attributes.status) params.status = /** @type {any} */ (parsed.attributes.status);
|
||||
if (parsed.attributes.project) params.project = parsed.attributes.project;
|
||||
if (parsed.attributes.priority) params.priority = parsed.attributes.priority;
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a token matching a given prefix from a string.
|
||||
* Used by PropertyPills smart replace: e.g. removeTokenByPrefix("buy milk due:tomorrow", "due:")
|
||||
* returns "buy milk"
|
||||
* @param {string} input
|
||||
* @param {string} prefix
|
||||
* @returns {string}
|
||||
*/
|
||||
export function removeTokenByPrefix(input, prefix) {
|
||||
const tokens = input.split(/\s+/);
|
||||
const filtered = tokens.filter(t => !t.toLowerCase().startsWith(prefix.toLowerCase()));
|
||||
return filtered.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate filter tokens from user input that are already in the active filter.
|
||||
* Prevents submitting "+grocer +grocer" when filter is +grocer and user also typed +grocer.
|
||||
* @param {string} userInput
|
||||
* @param {string} filterStr
|
||||
* @returns {string}
|
||||
*/
|
||||
export function mergeInputWithFilter(userInput, filterStr) {
|
||||
if (!filterStr || !filterStr.trim()) return userInput;
|
||||
if (!userInput || !userInput.trim()) return filterStr;
|
||||
|
||||
const filterTokens = new Set(filterStr.trim().split(/\s+/).map(t => t.toLowerCase()));
|
||||
const inputTokens = userInput.trim().split(/\s+/);
|
||||
const deduped = inputTokens.filter(t => !filterTokens.has(t.toLowerCase()));
|
||||
|
||||
const combined = [...deduped, filterStr.trim()].filter(Boolean).join(' ');
|
||||
return combined;
|
||||
}
|
||||
@@ -29,11 +29,11 @@
|
||||
height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr min(var(--content-max-width), 100%) 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-rows: 1fr auto auto;
|
||||
grid-template-areas:
|
||||
". header ."
|
||||
". content ."
|
||||
". input .";
|
||||
". input ."
|
||||
". header .";
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.js';
|
||||
import { tasksStore } from '$lib/stores/tasks.js';
|
||||
import { activeFilter, clearFilter } from '$lib/stores/filters.js';
|
||||
import { parseFilterString, filterToParams } from '$lib/utils/filters.js';
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
import InputBar from '$lib/components/InputBar.svelte';
|
||||
@@ -24,11 +26,61 @@
|
||||
return;
|
||||
}
|
||||
|
||||
loadReport(activeReport);
|
||||
// Load with existing active filter if any
|
||||
if ($activeFilter) {
|
||||
loadWithFilter($activeFilter);
|
||||
} else {
|
||||
loadReport(activeReport);
|
||||
}
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
// React to filter changes
|
||||
$: if ($activeFilter !== undefined) {
|
||||
handleFilterChange($activeFilter);
|
||||
}
|
||||
|
||||
/** @type {string|null|undefined} */
|
||||
let lastFilter = undefined;
|
||||
|
||||
/**
|
||||
* @param {string|null} filter
|
||||
*/
|
||||
function handleFilterChange(filter) {
|
||||
// Skip initial (undefined -> initial value)
|
||||
if (lastFilter === undefined) {
|
||||
lastFilter = filter;
|
||||
return;
|
||||
}
|
||||
// Skip if same value
|
||||
if (filter === lastFilter) return;
|
||||
lastFilter = filter;
|
||||
|
||||
if (filter) {
|
||||
loadWithFilter(filter);
|
||||
} else {
|
||||
loadReport(activeReport);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filterStr
|
||||
*/
|
||||
async function loadWithFilter(filterStr) {
|
||||
loading = true;
|
||||
inputError = '';
|
||||
try {
|
||||
const parsed = parseFilterString(filterStr);
|
||||
const params = filterToParams(parsed);
|
||||
await tasksStore.load(params);
|
||||
} catch (error) {
|
||||
console.error('Failed to load filtered tasks:', error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} reportName
|
||||
*/
|
||||
@@ -49,6 +101,10 @@
|
||||
*/
|
||||
function handleReportChange(reportName) {
|
||||
activeReport = reportName;
|
||||
// Changing report clears any active filter
|
||||
if ($activeFilter) {
|
||||
clearFilter();
|
||||
}
|
||||
loadReport(reportName);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user