un poquito mejor los estilos

webui
borja 2 weeks ago
parent 05900ef977
commit e34df4c370

@ -1,462 +1,461 @@
<script lang="ts"> <script lang="ts">
import Badge from "$lib/ui/atoms/Badge.svelte"; import Badge from "$lib/ui/atoms/Badge.svelte";
import { import {
compareYmd, compareYmd,
todayYmdUTC, todayYmdUTC,
ymdToDmy, ymdToDmy,
isToday, isToday,
isTomorrow, isTomorrow,
} from "$lib/utils/date"; } from "$lib/utils/date";
import { success, error as toastError } from "$lib/stores/toasts"; import { success, error as toastError } from "$lib/stores/toasts";
import { tick } from "svelte"; import { tick } from "svelte";
export let id: number; export let id: number;
export let description: string; export let description: string;
export let due_date: string | null = null; export let due_date: string | null = null;
export let display_code: number | null = null; export let display_code: number | null = null;
export let assignees: string[] = []; export let assignees: string[] = [];
export let currentUserId: string | null | undefined = null; export let currentUserId: string | null | undefined = null;
export let completed: boolean = false; export let completed: boolean = false;
export let completed_at: string | null = null; export let completed_at: string | null = null;
const code = display_code ?? id; const code = display_code ?? id;
const codeStr = String(code).padStart(4, "0"); const codeStr = String(code).padStart(4, "0");
$: isAssigned = currentUserId ? assignees.includes(currentUserId) : false; $: isAssigned = currentUserId ? assignees.includes(currentUserId) : false;
$: today = todayYmdUTC(); $: today = todayYmdUTC();
$: overdue = !!due_date && compareYmd(due_date, today) < 0; $: overdue = !!due_date && compareYmd(due_date, today) < 0;
$: imminent = !!due_date && (isToday(due_date) || isTomorrow(due_date)); $: imminent = !!due_date && (isToday(due_date) || isTomorrow(due_date));
$: dateDmy = due_date ? ymdToDmy(due_date) : ""; $: dateDmy = due_date ? ymdToDmy(due_date) : "";
let editing = false; let editing = false;
let dateValue: string = due_date ?? ""; let dateValue: string = due_date ?? "";
let busy = false; let busy = false;
// Edición de texto (inline) // Edición de texto (inline)
let editingText = false; let editingText = false;
let descEl: HTMLElement | null = null; let descEl: HTMLElement | null = null;
async function doClaim() { async function doClaim() {
if (busy) return; if (busy) return;
busy = true; busy = true;
try { try {
const res = await fetch(`/api/tasks/${id}/claim`, { method: "POST" }); const res = await fetch(`/api/tasks/${id}/claim`, { method: "POST" });
if (res.ok) { if (res.ok) {
success("Tarea reclamada"); success("Tarea reclamada");
location.reload(); location.reload();
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || "No se pudo reclamar"); toastError(txt || "No se pudo reclamar");
} }
} catch (e) { } catch (e) {
toastError("Error de red"); toastError("Error de red");
} finally { } finally {
busy = false; busy = false;
} }
} }
async function doComplete() { async function doComplete() {
if (busy || completed) return; if (busy || completed) return;
busy = true; busy = true;
try { try {
const res = await fetch(`/api/tasks/${id}/complete`, { method: "POST" }); const res = await fetch(`/api/tasks/${id}/complete`, { method: "POST" });
if (res.ok) { if (res.ok) {
success("Tarea completada"); success("Tarea completada");
location.reload(); location.reload();
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || "No se pudo completar la tarea"); toastError(txt || "No se pudo completar la tarea");
} }
} catch { } catch {
toastError("Error de red"); toastError("Error de red");
} finally { } finally {
busy = false; busy = false;
} }
} }
async function doUnassign() { async function doUnassign() {
if (busy) return; if (busy) return;
busy = true; busy = true;
try { try {
const res = await fetch(`/api/tasks/${id}/unassign`, { method: "POST" }); const res = await fetch(`/api/tasks/${id}/unassign`, { method: "POST" });
if (res.ok) { if (res.ok) {
success("Asignación eliminada"); success("Asignación eliminada");
location.reload(); location.reload();
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || "No se pudo soltar"); toastError(txt || "No se pudo soltar");
} }
} catch { } catch {
toastError("Error de red"); toastError("Error de red");
} finally { } finally {
busy = false; busy = false;
} }
} }
async function saveDate() { async function saveDate() {
if (busy) return; if (busy) return;
busy = true; busy = true;
try { try {
const body = { const body = {
due_date: due_date:
dateValue && dateValue.trim() !== "" ? dateValue.trim() : null, dateValue && dateValue.trim() !== "" ? dateValue.trim() : null,
}; };
const res = await fetch(`/api/tasks/${id}`, { const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (res.ok) { if (res.ok) {
success("Fecha actualizada"); success("Fecha actualizada");
location.reload(); location.reload();
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || "No se pudo actualizar la fecha"); toastError(txt || "No se pudo actualizar la fecha");
} }
} catch { } catch {
toastError("Error de red"); toastError("Error de red");
} finally { } finally {
busy = false; busy = false;
} }
} }
function toggleEdit() { function toggleEdit() {
editing = !editing; editing = !editing;
if (editing) editingText = false; if (editing) editingText = false;
dateValue = due_date ?? ""; dateValue = due_date ?? "";
} }
function clearDate() { function clearDate() {
if (busy) return; if (busy) return;
if (!confirm("¿Quitar la fecha de vencimiento?")) return; if (!confirm("¿Quitar la fecha de vencimiento?")) return;
dateValue = ""; dateValue = "";
saveDate(); saveDate();
} }
function toggleEditText() { function toggleEditText() {
editingText = !editingText; editingText = !editingText;
if (editingText) { if (editingText) {
editing = false; editing = false;
// Asegurar que el elemento refleja el texto actual y enfocarlo // Asegurar que el elemento refleja el texto actual y enfocarlo
if (descEl) { if (descEl) {
descEl.textContent = description; descEl.textContent = description;
} }
tick().then(() => { tick().then(() => {
if (descEl) { if (descEl) {
descEl.focus(); descEl.focus();
placeCaretAtEnd(descEl); placeCaretAtEnd(descEl);
} }
}); });
} }
} }
function placeCaretAtEnd(el: HTMLElement) { function placeCaretAtEnd(el: HTMLElement) {
const range = document.createRange(); const range = document.createRange();
range.selectNodeContents(el); range.selectNodeContents(el);
range.collapse(false); range.collapse(false);
const sel = window.getSelection(); const sel = window.getSelection();
sel?.removeAllRanges(); sel?.removeAllRanges();
sel?.addRange(range); sel?.addRange(range);
} }
async function saveText() { async function saveText() {
if (busy) return; if (busy) return;
const raw = (descEl?.textContent || "").replace(/\s+/g, " ").trim(); const raw = (descEl?.textContent || "").replace(/\s+/g, " ").trim();
if (raw.length < 1 || raw.length > 1000) { if (raw.length < 1 || raw.length > 1000) {
toastError("La descripción debe tener entre 1 y 1000 caracteres."); toastError("La descripción debe tener entre 1 y 1000 caracteres.");
return; return;
} }
if (raw === description) { if (raw === description) {
editingText = false; editingText = false;
return; return;
} }
busy = true; busy = true;
try { try {
const res = await fetch(`/api/tasks/${id}`, { const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ description: raw }), body: JSON.stringify({ description: raw }),
}); });
if (res.ok) { if (res.ok) {
description = raw; description = raw;
success("Descripción actualizada"); success("Descripción actualizada");
editingText = false; editingText = false;
} else { } else {
const txt = await res.text(); const txt = await res.text();
toastError(txt || "No se pudo actualizar la descripción"); toastError(txt || "No se pudo actualizar la descripción");
} }
} catch { } catch {
toastError("Error de red"); toastError("Error de red");
} finally { } finally {
busy = false; busy = false;
} }
} }
function cancelText() { function cancelText() {
if (descEl) { if (descEl) {
descEl.textContent = description; descEl.textContent = description;
} }
editingText = false; editingText = false;
} }
</script> </script>
<li class="task" class:completed> <li class="task" class:completed>
<div class="left"> <div class="code">#{codeStr}</div>
<span class="code">#{codeStr}</span> <div
<span class="desc"
class="desc" class:editing={editingText}
class:editing={editingText} class:completed
class:completed contenteditable={editingText && !completed}
contenteditable={editingText && !completed} role="textbox"
role="textbox" aria-label="Descripción de la tarea"
aria-label="Descripción de la tarea" spellcheck="true"
spellcheck="true" bind:this={descEl}
bind:this={descEl} on:keydown={(e) => {
on:keydown={(e) => { if (e.key === "Escape") {
if (e.key === "Escape") { e.preventDefault();
e.preventDefault(); cancelText();
cancelText(); } else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
} else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault();
e.preventDefault(); saveText();
saveText(); } else if (e.key === "Enter") {
} else if (e.key === "Enter") { e.preventDefault();
e.preventDefault(); }
} }}
}}>{description}</span >
> {description}
</div> </div>
<div class="meta"> <div class="meta">
{#if due_date} {#if due_date}
<span <span
class="date-badge" class="date-badge"
class:overdue class:overdue
class:soon={imminent} class:soon={imminent}
title={overdue ? "Vencida" : imminent ? "Próxima" : "Fecha"} title={overdue ? "Vencida" : imminent ? "Próxima" : "Fecha"}
> >
📅 {dateDmy}{#if overdue} 📅 {dateDmy}{#if overdue}
{/if} {/if}
</span> </span>
{/if} {/if}
</div> </div>
{#if assignees?.length} {#if assignees?.length}
<div class="assignees"> <div class="assignees">
{#each assignees as a} {#each assignees as a}
<span class="assignee" title={a}>👤</span> <span class="assignee" title={a}>👤</span>
{/each} {/each}
</div> </div>
{/if} {/if}
<div class="actions"> <div class="actions">
{#if !completed} {#if !completed}
{#if !isAssigned} {#if !isAssigned}
<button <button
class="icon-btn" class="icon-btn"
aria-label="Reclamar" aria-label="Reclamar"
on:click|preventDefault={doClaim} on:click|preventDefault={doClaim}
disabled={busy}>Reclamar</button disabled={busy}>Reclamar</button
> >
{:else} {:else}
<button <button
class="icon-btn" class="icon-btn"
aria-label="Soltar" aria-label="Soltar"
title="Soltar" title="Soltar"
on:click|preventDefault={doUnassign} on:click|preventDefault={doUnassign}
disabled={busy}>🫳</button disabled={busy}>🫳</button
> >
{/if} {/if}
<button <button
class="icon-btn" class="icon-btn"
aria-label="Completar" aria-label="Completar"
title="Completar" title="Completar"
on:click|preventDefault={doComplete} on:click|preventDefault={doComplete}
disabled={busy}>✅</button disabled={busy}>✅</button
> >
{#if !editingText} {#if !editingText}
<button <button
class="icon-btn" class="icon-btn"
aria-label="Editar texto" aria-label="Editar texto"
title="Editar texto" title="Editar texto"
on:click|preventDefault={toggleEditText} on:click|preventDefault={toggleEditText}
disabled={busy}>✍️</button disabled={busy}>✍️</button
> >
{:else} {:else}
<button <button
class="btn primary" class="btn primary"
on:click|preventDefault={saveText} on:click|preventDefault={saveText}
disabled={busy}>Guardar</button disabled={busy}>Guardar</button
> >
<button <button
class="btn ghost" class="btn ghost"
on:click|preventDefault={cancelText} on:click|preventDefault={cancelText}
disabled={busy}>Cancelar</button disabled={busy}>Cancelar</button
> >
{/if} {/if}
{#if !editing} {#if !editing}
<button <button
class="icon-btn" class="icon-btn"
aria-label="Editar fecha" aria-label="Editar fecha"
title="Editar fecha" title="Editar fecha"
on:click|preventDefault={toggleEdit} on:click|preventDefault={toggleEdit}
disabled={busy}>🗓️</button disabled={busy}>🗓️</button
> >
{:else} {:else}
<input class="date" type="date" bind:value={dateValue} /> <input class="date" type="date" bind:value={dateValue} />
<button <button
class="btn primary" class="btn primary"
on:click|preventDefault={saveDate} on:click|preventDefault={saveDate}
disabled={busy}>Guardar</button disabled={busy}>Guardar</button
> >
<button <button
class="btn danger" class="btn danger"
on:click|preventDefault={clearDate} on:click|preventDefault={clearDate}
disabled={busy}>Quitar</button disabled={busy}>Quitar</button
> >
<button <button
class="btn ghost" class="btn ghost"
on:click|preventDefault={toggleEdit} on:click|preventDefault={toggleEdit}
disabled={busy}>Cancelar</button disabled={busy}>Cancelar</button
> >
{/if} {/if}
{/if} {/if}
</div> </div>
</li> </li>
<style> <style>
.task { .task {
display: grid; display: grid;
grid-template-columns: 2fr 1fr; grid-template-columns: 1fr max-content;
grid-template-rows: max-content max-content; grid-template-rows: min-content max-content max-content;
grid-gap: 2px; grid-gap: 2px;
padding: 8px 0; padding: 4px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
position: relative; position: relative;
margin: 0 0 4px 0; margin: 0 0 4px 0;
} }
.task:last-child { .task:last-child {
border-bottom: 0; border-bottom: 0;
} }
.left { .code {
display: inline-flex; font-weight: 600;
align-items: center; font-size: 0.8rem;
gap: var(--space-2); color: var(--color-text-muted);
flex-wrap: wrap; font-family: monospace;
min-width: 0; letter-spacing: 0.5px;
} grid-row: 1/2;
.code { grid-column: 1/2;
font-weight: 600; }
font-size: 0.8rem; .completed {
color: var(--color-text-muted); opacity: 0.5;
font-family: monospace; }
letter-spacing: 0.5px; .desc {
position: absolute; padding: 8px;
top: 0px; grid-column: 1/3;
left: 2px; grid-row: 2/3;
} }
.desc {
padding: 8px;
grid-column: 1/2;
grid-row: 1/3;
}
.desc.editing { .desc.editing {
outline: 2px solid var(--color-primary); outline: 2px solid var(--color-primary);
outline-offset: 2px; outline-offset: 2px;
background: var(--color-surface); background: var(--color-surface);
padding: 2px 4px; padding: 2px 4px;
border-radius: 4px; border-radius: 4px;
white-space: normal; white-space: normal;
text-overflow: clip; text-overflow: clip;
grid-column: 2/3; grid-column: 1/3;
grid-row: 2/3; grid-row: 2/3;
} margin: 16px 0;
.desc.completed { }
text-decoration: line-through; .desc.completed {
} text-decoration: line-through;
.meta { }
justify-self: end; .meta {
align-items: start; justify-self: end;
gap: 8px; align-items: start;
} gap: 8px;
.muted { grid-row: 1/2;
color: var(--color-text-muted); grid-column: 2/3;
} }
.date-badge { .muted {
padding: 2px 6px; color: var(--color-text-muted);
border-radius: 6px; }
border: 1px solid transparent; .date-badge {
font-size: 12px; padding: 2px 6px;
} border-radius: 6px;
.date-badge.overdue { border: 1px solid transparent;
border-color: var(--color-danger); font-size: 12px;
} }
.date-badge.soon { .date-badge.overdue {
border-color: var(--color-warning); border-color: var(--color-danger);
} }
.assignees { .date-badge.soon {
grid-column: 1/2; border-color: var(--color-warning);
text-align: left; }
align-self: end; .assignees {
} grid-row: 3/4;
.assignee { grid-column: 1/2;
display: inline-flex; text-align: left;
align-items: center; align-self: end;
justify-content: center; }
width: 20px; .assignee {
height: 20px; display: inline-flex;
} align-items: center;
.task.completed { justify-content: center;
opacity: 0.7; width: 20px;
} height: 20px;
}
.task.completed {
opacity: 0.7;
}
.actions { .actions {
display: inline-flex; display: block;
gap: 6px; gap: 6px;
justify-self: end; justify-self: end;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
grid-column: 2/3; grid-column: 2/3;
grid-row: 2/3; grid-row: 3/4;
} }
.btn { .btn {
padding: 4px 8px; padding: 4px 8px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
background: var(--color-surface); background: var(--color-surface);
color: var(--color-text); color: var(--color-text);
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
} }
.btn[disabled] { .btn[disabled] {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.btn.primary { .btn.primary {
border-color: transparent; border-color: transparent;
background: var(--color-primary); background: var(--color-primary);
color: #fff; color: #fff;
} }
.btn.secondary { .btn.secondary {
} }
.btn.ghost { .btn.ghost {
background: transparent; background: transparent;
} }
.btn.danger { .btn.danger {
background: var(--color-danger); background: var(--color-danger);
color: #fff; color: #fff;
border-color: transparent; border-color: transparent;
} }
.icon-btn { .icon-btn {
padding: 6px 8px; padding: 6px 8px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
background: var(--color-surface); background: var(--color-surface);
font-size: 16px; font-size: 16px;
line-height: 1; line-height: 1;
} }
.date { .date {
padding: 4px 6px; padding: 4px 6px;
font-size: 14px; font-size: 14px;
} }
</style> </style>

Loading…
Cancel
Save