AI tracker: /trackers/ai with public news, owner-only bots and usage; ai schema (news_items, bot_status, usage_entries); idempotent import scripts; preflight ai grants check
Deploy production / deploy (push) Has been cancelled
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
1 parent
9b21220280
commit
cf0fec973c
24 files changed
+38866
-3
No files matched your search
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right';
|
||||
import Cpu from 'lucide-svelte/icons/cpu';
|
||||
import Code from 'lucide-svelte/icons/code';
|
||||
import Layers from 'lucide-svelte/icons/layers';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
type Filter = 'all' | 'ai' | 'software';
|
||||
let filter = $state<Filter>('all');
|
||||
|
||||
const filters = [
|
||||
{ key: 'all', label: 'all', icon: Layers },
|
||||
{ key: 'ai', label: 'ai', icon: Cpu },
|
||||
{ key: 'software', label: 'software', icon: Code }
|
||||
] as const;
|
||||
|
||||
const counts = $derived({
|
||||
all: data.news.length,
|
||||
ai: data.news.filter((n) => n.section === 'ai').length,
|
||||
software: data.news.filter((n) => n.section === 'software').length
|
||||
});
|
||||
|
||||
const days = $derived.by(() => {
|
||||
const groups: { date: string; items: typeof data.news }[] = [];
|
||||
for (const item of data.news) {
|
||||
if (filter !== 'all' && item.section !== filter) continue;
|
||||
const last = groups.at(-1);
|
||||
if (last?.date === item.date) last.items.push(item);
|
||||
else groups.push({ date: item.date, items: [item] });
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const dayLabel = (iso: string) =>
|
||||
new Date(`${iso}T12:00:00Z`).toLocaleDateString('en-GB', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'Europe/Amsterdam'
|
||||
});
|
||||
const host = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="filters" role="group" aria-label="Filter by section">
|
||||
{#each filters as f (f.key)}
|
||||
<button
|
||||
type="button"
|
||||
class={['chip', `chip-${f.key}`, { on: filter === f.key }]}
|
||||
aria-pressed={filter === f.key}
|
||||
onclick={() => (filter = f.key)}
|
||||
><span class="dot"><f.icon size={12} /></span>{f.label}<span class="count"
|
||||
>{counts[f.key]}</span
|
||||
></button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !days.length}
|
||||
<p class="empty">No news yet. The digest lands here every weekday.</p>
|
||||
{:else}
|
||||
<div class="days">
|
||||
{#each days as day (day.date)}
|
||||
<section class="day" aria-label={dayLabel(day.date)}>
|
||||
<h2><time datetime={day.date}>{dayLabel(day.date)}</time></h2>
|
||||
<ul>
|
||||
{#each day.items as item (item.id)}
|
||||
<li>
|
||||
<a class="item" href={item.url} target="_blank" rel="noopener noreferrer">
|
||||
<span class={['badge', `badge-${item.section}`]}
|
||||
>{#if item.section === 'software'}<Code size={13} />{:else}<Cpu
|
||||
size={13}
|
||||
/>{/if}</span
|
||||
>
|
||||
<span class="body">
|
||||
<span class="title">{item.title}</span>
|
||||
{#if item.note}<span class="note">{item.note}</span>{/if}
|
||||
<span class="host">{host(item.url)}</span>
|
||||
</span>
|
||||
<ArrowUpRight size={16} class="arrow" aria-hidden="true" />
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.6rem 0.25rem 0.3rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.chip:hover {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-muted);
|
||||
}
|
||||
.chip.on {
|
||||
color: var(--site-text);
|
||||
border-color: var(--site-text);
|
||||
}
|
||||
.dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
}
|
||||
.chip-all .dot {
|
||||
background: linear-gradient(135deg, #a1a1aa, #52525b);
|
||||
}
|
||||
.chip-ai .dot,
|
||||
.badge-ai {
|
||||
background: linear-gradient(135deg, #fda4af, #8b5cf6);
|
||||
}
|
||||
.chip-software .dot,
|
||||
.badge-software {
|
||||
background: linear-gradient(135deg, #5eead4, #0284c7);
|
||||
}
|
||||
.count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.empty {
|
||||
color: var(--site-muted);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.days {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.day h2 {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--site-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.day ul {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.7rem;
|
||||
height: 100%;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
color: var(--site-text);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.item:hover {
|
||||
border-color: var(--site-muted);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px -12px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
.item :global(.arrow) {
|
||||
flex-shrink: 0;
|
||||
color: var(--site-muted);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.item:hover :global(.arrow) {
|
||||
opacity: 1;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.5rem;
|
||||
color: #fff;
|
||||
}
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.title {
|
||||
font-weight: 550;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.note {
|
||||
font-size: 0.83rem;
|
||||
color: var(--site-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.host {
|
||||
font-size: 0.72rem;
|
||||
color: var(--site-muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user