Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "cron-builder-parser",
"private": true,
"version": "1.4.1",
"version": "1.5.0",
"description": "Strict POSIX cron: Builder & Parser",
"author": "Daschi (https://github.com/Daschi1)",
"repository": {
Expand Down
3 changes: 3 additions & 0 deletions src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Performance/SEO hints for external analytics script -->
<link rel="dns-prefetch" href="//assets.onedollarstats.com" />
<link rel="preconnect" href="https://assets.onedollarstats.com" crossorigin />
<script defer src="https://assets.onedollarstats.com/stonks.js"></script>
%sveltekit.head%
</head>
Expand Down
5 changes: 3 additions & 2 deletions src/lib/components/Footer.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import Container from "$lib/ui/Container.svelte";
import FancyHeart from "$lib/ui/FancyHeart.svelte";
</script>

<footer
Expand All @@ -24,8 +25,8 @@
Licenses
</a>
</nav>
<p class="text-slate-400">
Created with <span aria-hidden="true">❤️</span><span class="sr-only">love</span> by
<p class="love-line text-slate-400">
Created with <FancyHeart title="Love" /><span class="sr-only">love</span> by
<a
class="text-emerald-300 underline underline-offset-2 transition-colors hover:text-emerald-200 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 focus-visible:outline-none"
href="https://github.com/Daschi1"
Expand Down
141 changes: 141 additions & 0 deletions src/lib/ui/FancyHeart.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<script lang="ts">
export let title: string | undefined = undefined;
</script>

<span aria-hidden="true" class="fancy-heart" {title}>♥️</span>

<style>
.fancy-heart {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0 0.05rem;
border-radius: 0.25rem;
color: #ef4444; /* tailwind rose-500-ish on dark */
transition:
color 200ms ease,
text-shadow 200ms ease,
transform 200ms ease;
outline: none;
}

/* Self hover/focus (if placed inside a focusable element) */
.fancy-heart:hover,
.fancy-heart:focus-visible {
color: #fb7185; /* a bit brighter */
text-shadow:
0 0 10px rgba(251, 113, 133, 0.6),
0 0 18px rgba(244, 63, 94, 0.4);
animation: heart-beat 900ms ease-in-out infinite;
}

/* Activate when the whole love line is hovered or focused (keyboard focus within) */
/* This is used in the footer */
/*noinspection CssUnusedSymbol*/
:global(.love-line:hover) .fancy-heart,
:global(.love-line:focus-within) .fancy-heart {
color: #fb7185; /* a bit brighter */
text-shadow:
0 0 10px rgba(251, 113, 133, 0.6),
0 0 18px rgba(244, 63, 94, 0.4);
animation: heart-beat 900ms ease-in-out infinite;
}

/* Sparkles */
.fancy-heart::before,
.fancy-heart::after {
content: "✨";
position: absolute;
font-size: 0.8em;
opacity: 0;
filter: drop-shadow(0 0 6px rgba(250, 204, 21, 0.6));
pointer-events: none;
}
.fancy-heart::before {
left: -0.5rem;
top: -0.35rem;
}
.fancy-heart::after {
right: -0.55rem;
top: -0.2rem;
}

.fancy-heart:hover::before,
.fancy-heart:focus-visible::before {
animation: sparkle-pop-left 900ms ease-out forwards;
}
.fancy-heart:hover::after,
.fancy-heart:focus-visible::after {
animation: sparkle-pop-right 900ms ease-out forwards;
animation-delay: 120ms;
}

@keyframes heart-beat {
0%,
100% {
transform: scale(1);
}
20% {
transform: scale(1.18);
}
35% {
transform: scale(0.96);
}
55% {
transform: scale(1.12);
}
75% {
transform: scale(0.99);
}
}
@keyframes sparkle-pop-left {
0% {
transform: translate(0, 0) rotate(-8deg) scale(0.6);
opacity: 0;
}
15% {
opacity: 1;
}
60% {
transform: translate(-4px, -10px) rotate(-18deg) scale(1);
opacity: 1;
}
100% {
transform: translate(-8px, -18px) rotate(-28deg) scale(0.8);
opacity: 0;
}
}
@keyframes sparkle-pop-right {
0% {
transform: translate(0, 0) rotate(8deg) scale(0.6);
opacity: 0;
}
15% {
opacity: 1;
}
60% {
transform: translate(4px, -10px) rotate(18deg) scale(1);
opacity: 1;
}
100% {
transform: translate(8px, -18px) rotate(28deg) scale(0.8);
opacity: 0;
}
}

/* Be kind to folks with motion sensitivity */
@media (prefers-reduced-motion: reduce) {
.fancy-heart,
.fancy-heart::before,
.fancy-heart::after {
animation: none !important;
transition: none !important;
}
.fancy-heart:hover,
.fancy-heart:focus-visible {
text-shadow: none;
}
}
</style>
149 changes: 44 additions & 105 deletions src/lib/ui/FieldChips.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import type { FieldSpec } from "$lib/utils/cron";
import { toggleValue, setEvery } from "$lib/utils/cron";
import { setEvery, toggleValue } from "$lib/utils/cron";
import { onMount, tick } from "svelte";

let {
Expand All @@ -25,14 +25,9 @@
title: string; // tooltip
};

let containerEl: HTMLDivElement | null = null;
let measureEl: HTMLDivElement | null = null;
let rows: number[][] = $state([]);
let items: Item[] = $state([]);
let widths: number[] = $state([]);
let containerWidth = $state(0);

const GAP_PX = 8; // Tailwind gap-2
let containerEl: HTMLDivElement | null = null;
let chipW: number | null = $state(null);

function buildItems(): Item[] {
const out: Item[] = [];
Expand All @@ -55,96 +50,57 @@
return out;
}

async function measure() {
if (!measureEl) return;
// Ensure DOM is updated
await tick();
const btns = Array.from(measureEl.querySelectorAll("button"));
widths = btns.map((b) => Math.ceil(b.getBoundingClientRect().width));
function recalc() {
items = buildItems();
}

function packRows() {
if (!containerWidth || widths.length !== items.length) {
rows = [items.map((_, i) => i)];
async function applyUniformWidth() {
await tick();
const root = containerEl;
if (!root) return;
const btns = Array.from(root.querySelectorAll<HTMLButtonElement>('button[data-kind="value"]'));
if (btns.length === 0) {
chipW = null;
return;
}
const maxWidth = containerWidth;
const out: number[][] = [];
let i = 0;
let prevRowWidth = Number.POSITIVE_INFINITY;
while (i < items.length) {
let row: number[] = [];
let used = 0;
const limit = Math.min(maxWidth, prevRowWidth);
while (i < items.length) {
const w = widths[i];
const nextUsed = row.length === 0 ? w : used + GAP_PX + w;
if (nextUsed <= limit) {
row.push(i);
used = nextUsed;
i++;
} else {
break;
}
}
if (row.length === 0) {
// Fallback to place one item to avoid infinite loop
row.push(i);
used = widths[i] || 0;
i++;
}
out.push(row);
prevRowWidth = used;
}
rows = out;
}

function recalc() {
items = buildItems();
const widths = btns.map((b) => Math.ceil(b.getBoundingClientRect().width));
chipW = Math.max(...widths);
}

let ro: ResizeObserver | null = null;
onMount(() => {
recalc();
// Observe container for width changes (including responsive font-size changes)
ro = new ResizeObserver((entries) => {
for (const entry of entries) {
containerWidth = Math.floor(entry.contentRect.width);
// Re-measure because media queries may alter widths
measure().then(packRows);
}
// Initial calc when mounted
applyUniformWidth();
ro = new ResizeObserver(() => {
applyUniformWidth();
});
if (containerEl) ro.observe(containerEl);
// Initial measure/pack
measure().then(packRows);
return () => {
if (ro && containerEl) ro.unobserve(containerEl);
ro = null;
};
});

// Recompute items and re-measure when props change
// Recompute items when inputs change
$effect(() => {
// Depend on labels, min, max
const _l = labels ? labels.join("|") : "";
const _min = min;
const _max = max;
void _l;
void _min;
void _max;
recalc();
// After items update, measure and pack
measure().then(packRows);
// After items change, recompute max width
applyUniformWidth();
});

// Repack when selection may visually affect layout (unlikely, but safe)
// Also re-apply on selection changes (pressed styles can slightly affect width)
$effect(() => {
const any = spec.any;
const count = spec.values.length;
void any;
void count;
// No need to rebuild items; just pack in case slight width changes occur
measure().then(packRows);
applyUniformWidth();
});

function onEvery() {
Expand All @@ -159,49 +115,32 @@
<div class="space-y-2">
<div class="text-sm font-semibold text-slate-300">{title}</div>

<!-- Measurement container (offscreen, invisible) -->
<div class="invisible absolute top-0 left-0 -z-50">
<div class="flex gap-2" bind:this={measureEl} aria-hidden="true">
{#each items as it (it.key)}
<div class="flex w-full flex-wrap items-start gap-2" bind:this={containerEl}>
{#each items as it (it.key)}
{#if it.type === "every"}
<button
type="button"
class="chip cursor-pointer rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm font-medium md:px-2.5 md:py-1.5 md:text-xs"
data-kind="every"
class="chip inline-flex cursor-pointer items-center justify-center rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 text-center font-mono text-sm font-medium whitespace-nowrap transition-colors hover:bg-neutral-800 hover:brightness-110 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 focus-visible:outline-none aria-pressed:border-emerald-500 aria-pressed:bg-emerald-900/40 aria-pressed:text-emerald-200 md:px-2.5 md:py-1.5"
aria-pressed={spec.any}
onclick={onEvery}
title={it.title}
>
{it.text}
</button>
{/each}
</div>
</div>

<!-- Visible rows container -->
<div class="flex w-full flex-col items-start gap-2" bind:this={containerEl}>
{#each rows as row, rIdx (rIdx)}
<div class="flex gap-2">
{#each row as idx (idx)}
{#if items[idx].type === "every"}
<button
type="button"
class="chip cursor-pointer rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 text-center text-sm font-medium transition-colors hover:bg-neutral-800 hover:brightness-110 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 focus-visible:outline-none aria-pressed:border-emerald-500 aria-pressed:bg-emerald-900/40 aria-pressed:text-emerald-200 md:px-2.5 md:py-1.5 md:text-xs"
aria-pressed={spec.any}
onclick={onEvery}
title={items[idx].title}
>{items[idx].text}
</button>
{:else}
<button
type="button"
class="chip cursor-pointer rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 text-center text-sm font-medium transition-colors hover:bg-neutral-800 hover:brightness-110 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 focus-visible:outline-none aria-pressed:border-emerald-500 aria-pressed:bg-emerald-900/40 aria-pressed:text-emerald-200 md:px-2.5 md:py-1.5 md:text-xs"
aria-pressed={!spec.any &&
items[idx].value !== undefined &&
spec.values.includes(items[idx].value)}
onclick={() => items[idx].value !== undefined && onToggle(items[idx].value)}
title={items[idx].title}
>
{items[idx].text}
</button>
{/if}
{/each}
</div>
{:else}
<button
type="button"
data-kind="value"
class="chip inline-flex flex-none cursor-pointer items-center justify-center rounded-md border border-neutral-800 bg-neutral-900 px-3 py-2 text-center font-mono text-sm font-medium whitespace-nowrap transition-colors hover:bg-neutral-800 hover:brightness-110 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 focus-visible:outline-none aria-pressed:border-emerald-500 aria-pressed:bg-emerald-900/40 aria-pressed:text-emerald-200 md:px-2.5 md:py-1.5"
style:width={chipW ? chipW + "px" : null}
aria-pressed={!spec.any && it.value !== undefined && spec.values.includes(it.value)}
onclick={() => it.value !== undefined && onToggle(it.value)}
title={it.title}
>
{it.text}
</button>
{/if}
{/each}
</div>
</div>
Loading