/ src / components / notes / NotesTabs.svelte
NotesTabs.svelte
  1  <script lang="ts">
  2  import { onMount } from "svelte";
  3  import ArticleCard from "./ArticleCard.svelte";
  4  import MusicCard from "./MusicCard.svelte";
  5  import PeopleCard from "./PeopleCard.svelte";
  6  import VaultCard from "./VaultCard.svelte";
  7  
  8  interface Note {
  9  	id: string;
 10  	title: string;
 11  	description?: string;
 12  	category: string;
 13  	tags: string[];
 14  	created_at: string;
 15  	modified_at: string;
 16  	url?: string;
 17  	author?: string;
 18  	artist?: string;
 19  	album?: string;
 20  	year?: string[];
 21  	links?: string[];
 22  	backlink_count?: number;
 23  }
 24  
 25  interface Props {
 26  	notes: Note[];
 27  	filteredIds?: string[] | null;
 28  }
 29  
 30  let { notes, filteredIds = null }: Props = $props();
 31  
 32  const categories = ["article", "vault", "person", "music"] as const;
 33  type Category = (typeof categories)[number];
 34  
 35  const categoryLabels: Record<Category, string> = {
 36  	article: "Articles",
 37  	vault: "Vault",
 38  	person: "People",
 39  	music: "Music",
 40  };
 41  
 42  const categoryHashMap: Record<string, Category> = {
 43  	articles: "article",
 44  	vault: "vault",
 45  	people: "person",
 46  	music: "music",
 47  };
 48  
 49  const hashToCategoryMap: Record<Category, string> = {
 50  	article: "articles",
 51  	vault: "vault",
 52  	person: "people",
 53  	music: "music",
 54  };
 55  
 56  let activeTab = $state<Category>("article");
 57  
 58  let displayedNotes = $derived.by(() => {
 59  	let filtered = notes;
 60  	if (filteredIds !== null) {
 61  		filtered = notes.filter((n) => filteredIds?.includes(n.id));
 62  	}
 63  	return filtered.filter((n) => n.category === activeTab);
 64  });
 65  
 66  let categoryCounts = $derived.by(() => {
 67  	const counts: Record<Category, number> = {
 68  		article: 0,
 69  		vault: 0,
 70  		person: 0,
 71  		music: 0,
 72  	};
 73  	let filtered = notes;
 74  	if (filteredIds !== null) {
 75  		filtered = notes.filter((n) => filteredIds?.includes(n.id));
 76  	}
 77  	for (const note of filtered) {
 78  		if (counts[note.category as Category] !== undefined) {
 79  			counts[note.category as Category]++;
 80  		}
 81  	}
 82  	return counts;
 83  });
 84  
 85  function setTab(category: Category) {
 86  	activeTab = category;
 87  	const hash = hashToCategoryMap[category];
 88  	window.history.replaceState(null, "", `#${hash}`);
 89  }
 90  
 91  onMount(() => {
 92  	const hash = window.location.hash.slice(1);
 93  	if (hash && categoryHashMap[hash]) {
 94  		activeTab = categoryHashMap[hash];
 95  	}
 96  });
 97  </script>
 98  
 99  <div class="space-y-4">
100  	<!-- Tab bar -->
101  	<div class="relative flex flex-wrap gap-4 border-b border-pink-300/50 pb-2">
102  		<!-- floating indicator -->
103  		<span class="tab-indicator"></span>
104  		{#each categories as cat}
105  			{@const count = categoryCounts[cat]}
106  			{@const isActive = activeTab === cat}
107  			<button
108  				onclick={() => setTab(cat)}
109  				class="tab-btn relative flex items-center gap-2 text-sm font-medium transition-colors pb-1 cursor-pointer
110  					outline-none focus-visible:ring-2 focus-visible:ring-pink-400/40 rounded px-2 py-1
111  					{isActive ? 'text-pink-600' : 'text-pink-950/50 hover:text-pink-950/80'}"
112  				aria-pressed={isActive}
113  				style={isActive ? 'anchor-name: --active-tab' : ''}
114  			>
115  				{categoryLabels[cat]}
116  				<span
117  					class="text-xs px-1.5 py-0.5 rounded
118  						{isActive ? 'bg-pink-100 text-pink-600' : 'bg-pink-50 text-pink-400'}"
119  				>
120  					{count}
121  				</span>
122  			</button>
123  		{/each}
124  	</div>
125  
126  	<!-- Notes grid -->
127  	{#if displayedNotes.length === 0}
128  		<div class="text-center py-12 text-pink-950/50">
129  			<p>No notes found in this category.</p>
130  			{#if filteredIds !== null}
131  				<p class="text-sm mt-1">Try adjusting your search.</p>
132  			{/if}
133  		</div>
134  	{:else if activeTab === "article"}
135  		<div class="grid gap-2">
136  			{#each displayedNotes as note (note.id)}
137  				<ArticleCard
138  					id={note.id}
139  					title={note.title}
140  					description={note.description}
141  					tags={note.tags}
142  					created_at={note.created_at}
143  					url={note.url}
144  					author={note.author}
145  				/>
146  			{/each}
147  		</div>
148  	{:else if activeTab === "vault"}
149  		<div class="grid gap-2">
150  			{#each displayedNotes as note (note.id)}
151  				<VaultCard
152  					id={note.id}
153  					title={note.title}
154  					description={note.description}
155  					tags={note.tags}
156  					created_at={note.created_at}
157  				/>
158  			{/each}
159  		</div>
160  	{:else if activeTab === "music"}
161  		<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
162  			{#each displayedNotes as note (note.id)}
163  				<MusicCard
164  					id={note.id}
165  					title={note.title}
166  					tags={note.tags}
167  					artist={note.artist}
168  					album={note.album}
169  					year={note.year}
170  					description={note.description}
171  				/>
172  			{/each}
173  		</div>
174  	{:else if activeTab === "person"}
175  		<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
176  			{#each displayedNotes as note (note.id)}
177  				<PeopleCard
178  					id={note.id}
179  					title={note.title}
180  					tags={note.tags}
181  					backlink_count={note.backlink_count}
182  				/>
183  			{/each}
184  		</div>
185  	{/if}
186  </div>
187  
188  <style>
189  	.tab-indicator {
190  		position: absolute;
191  		position-anchor: --active-tab;
192  		bottom: -1px;
193  		left: anchor(left);
194  		width: anchor-size(width);
195  		height: 2px;
196  		background-color: var(--color-pink-500);
197  		transition:
198  			left 0.2s ease,
199  			width 0.2s ease;
200  	}
201  </style>