Skip to content

Commit ba1c8fd

Browse files
fix(announcements): fix multiple UI, validation, and content issues; update deps
fix(announcements): fix hydration mismatch and FOUC on card content The ReVanced API returns announcement content as rich HTML, including malformed tags, embedded <style> blocks, and unquoted attributes. Using {@html} during SSR caused the browser to restructure the DOM to correct these issues, producing a tree that no longer matched Svelte's expected hydration structure, resulting in: DOMException: Node.appendChild: Cannot add children to a Text In AnnouncementCard, replace {@html} with a plain-text derived value that strips HTML tags. This renders correctly on the server, eliminating both the hydration mismatch and the flash of empty content that appeared when the client-only renderHtml action was used as an interim fix. In Content (the detail view), replace {@html} with a use:renderHtml action that sets innerHTML client-side after hydration, safely rendering full rich HTML without a hydration conflict. The flash is acceptable here since this is a client-navigated detail panel, not SSR-critical. Additional changes in this commit: - fix(tooltip): rewrite ToolTip component using position:fixed and JS-driven positioning so tooltips on archived announcement cards are never clipped by overflow:hidden containers or viewport edges. Add max-width, multi-line wrapping, and centered text for readability. - fix(announcements): separate tag filters for active and archived sections. Active tags use URL-synced state; archived tags use independent local state. Clicking a tag in one section no longer affects the other. - fix(announcements): tags belonging only to archived announcements now appear exclusively inside the archive section (visible when expanded). Shared tags appear in both sections. - fix(announcements): strip <style> and <script> blocks (including their inner text) from HTML content before tag-stripping, so CSS rules no longer leak into card preview text. - fix(announcements): remove justify-content:space-between from card layout to eliminate the empty gap between timestamp and content. Tags are pinned to the card bottom via margin-top:auto instead. - fix(api): make 'status' field optional in AboutSchema and About type so upstream API responses (which omit it) no longer cause Zod validation failures on every page load. - fix(api/server): correct endpoint typo 'announcement/latest' to 'announcements/latest'. - fix(layout): access created_at via item.announcement.created_at instead of item.created_at in the layout banner filter. - fix(announcements): always send tags array in announcement update payload so clearing all tags is persisted correctly. - chore(deps): update project dependencies
1 parent e3a68c4 commit ba1c8fd

11 files changed

Lines changed: 587 additions & 446 deletions

File tree

package.json

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,36 +15,36 @@
1515
},
1616
"devDependencies": {
1717
"@eslint/compat": "^2.0.3",
18-
"@eslint/js": "^9.39.4",
18+
"@eslint/js": "^10.0.1",
1919
"@sveltejs/adapter-cloudflare": "^7.2.8",
2020
"@sveltejs/kit": "^2.55.0",
2121
"@sveltejs/vite-plugin-svelte": "^7.0.0",
2222
"@types/node": "^25.5.0",
23-
"eslint": "^10.0.3",
23+
"eslint": "^10.1.0",
2424
"eslint-config-prettier": "^10.1.8",
25-
"eslint-plugin-svelte": "^3.15.2",
25+
"eslint-plugin-svelte": "^3.16.0",
2626
"globals": "^17.4.0",
2727
"prettier": "^3.8.1",
2828
"prettier-plugin-svelte": "^3.5.1",
29-
"svelte": "^5.53.12",
29+
"svelte": "^5.55.0",
3030
"svelte-check": "^4.4.5",
31-
"typescript": "^5.9.3",
32-
"typescript-eslint": "^8.57.0",
33-
"vite": "^8.0.0",
34-
"wrangler": "^4.73.0"
31+
"typescript": "^6.0.2",
32+
"typescript-eslint": "^8.57.2",
33+
"vite": "^8.0.3",
34+
"wrangler": "^4.77.0"
3535
},
3636
"dependencies": {
3737
"@fontsource-variable/manrope": "^5.2.8",
38-
"@tanstack/query-persist-client-core": "^5.92.1",
39-
"@tanstack/query-sync-storage-persister": "^5.90.24",
40-
"@tanstack/svelte-query": "^6.1.0",
41-
"@tanstack/svelte-query-persist-client": "^6.0.22",
38+
"@tanstack/query-persist-client-core": "^5.95.2",
39+
"@tanstack/query-sync-storage-persister": "^5.95.2",
40+
"@tanstack/svelte-query": "^6.1.10",
41+
"@tanstack/svelte-query-persist-client": "^6.1.10",
4242
"fuse.js": "^7.1.0",
4343
"moment": "^2.30.1",
4444
"qrious": "^4.0.2",
4545
"runed": "^0.37.1",
4646
"svelte-material-icons": "^3.0.5",
47-
"svelte-meta-tags": "^4.5.0",
47+
"svelte-meta-tags": "^4.5.1",
4848
"zod": "^4.3.6"
4949
}
5050
}

pnpm-lock.yaml

Lines changed: 396 additions & 370 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/api/schemas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const AboutSchema = z.object({
4040
contact: ContactSchema,
4141
socials: z.array(SocialSchema),
4242
donations: DonationsSchema,
43-
status: z.string()
43+
status: z.string().optional()
4444
});
4545

4646
export const GpgKeySchema = z.object({

src/lib/api/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,5 @@ export async function fetchAnnouncements(fetchFn?: typeof fetch): Promise<Announ
6868
export async function fetchLatestAnnouncements(
6969
fetchFn?: typeof fetch
7070
): Promise<TaggedLatestAnnouncements[]> {
71-
return fetchJsonServer('announcement/latest', LatestAnnouncementsSchema, fetchFn);
71+
return fetchJsonServer('announcements/latest', LatestAnnouncementsSchema, fetchFn);
7272
}

src/lib/api/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export type About = {
3838
contact: Contact;
3939
socials: Social[];
4040
donations: Donations;
41-
status: string;
41+
status?: string;
4242
};
4343

4444
export type GpgKey = {

src/lib/components/atoms/ToolTip.svelte

Lines changed: 101 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts">
2+
import { browser } from '$app/environment';
23
import type { Snippet } from 'svelte';
34
45
type Props = {
@@ -9,11 +10,90 @@
910
};
1011
1112
let { content, children, tooltipContent, interactive = false }: Props = $props();
13+
14+
let wrapperEl = $state<HTMLElement | undefined>();
15+
let tooltipEl = $state<HTMLElement | undefined>();
16+
// Start off-screen so the first hover never flashes at a wrong position
17+
let posStyle = $state('left:-9999px;top:-9999px;');
18+
let visible = $state(false);
19+
let hideTimer: ReturnType<typeof setTimeout> | undefined;
20+
21+
const GAP = 8; // gap between trigger and tooltip
22+
const MARGIN = 8; // minimum distance from viewport edges
23+
24+
function reposition() {
25+
if (!browser || !wrapperEl || !tooltipEl) return;
26+
27+
const trigger = wrapperEl.getBoundingClientRect();
28+
const tw = tooltipEl.offsetWidth;
29+
const th = tooltipEl.offsetHeight;
30+
const vw = window.innerWidth;
31+
32+
// Center horizontally above the trigger, then clamp to viewport edges
33+
let x = trigger.left + trigger.width / 2 - tw / 2;
34+
x = Math.max(MARGIN, Math.min(x, vw - tw - MARGIN));
35+
36+
// Position above the trigger
37+
const y = trigger.top - th - GAP;
38+
39+
posStyle = `left:${x}px;top:${y}px;`;
40+
}
41+
42+
function show() {
43+
clearTimeout(hideTimer);
44+
reposition();
45+
visible = true;
46+
}
47+
48+
function scheduleHide() {
49+
if (interactive) {
50+
// Small delay lets the mouse travel from trigger to tooltip
51+
hideTimer = setTimeout(() => { visible = false; }, 100);
52+
} else {
53+
visible = false;
54+
}
55+
}
56+
57+
$effect(() => {
58+
if (!browser || !wrapperEl) return;
59+
60+
wrapperEl.addEventListener('mouseenter', show);
61+
wrapperEl.addEventListener('mouseleave', scheduleHide);
62+
wrapperEl.addEventListener('focusin', show);
63+
wrapperEl.addEventListener('focusout', scheduleHide);
64+
65+
return () => {
66+
wrapperEl!.removeEventListener('mouseenter', show);
67+
wrapperEl!.removeEventListener('mouseleave', scheduleHide);
68+
wrapperEl!.removeEventListener('focusin', show);
69+
wrapperEl!.removeEventListener('focusout', scheduleHide);
70+
};
71+
});
72+
73+
// Keep interactive tooltip open while the mouse is over it
74+
$effect(() => {
75+
if (!browser || !interactive || !tooltipEl) return;
76+
77+
tooltipEl.addEventListener('mouseenter', show);
78+
tooltipEl.addEventListener('mouseleave', scheduleHide);
79+
80+
return () => {
81+
tooltipEl!.removeEventListener('mouseenter', show);
82+
tooltipEl!.removeEventListener('mouseleave', scheduleHide);
83+
};
84+
});
1285
</script>
1386

14-
<div class="tooltip-wrapper" tabindex="-1" role="presentation">
87+
<div class="tooltip-wrapper" bind:this={wrapperEl} tabindex="-1" role="presentation">
1588
{@render children()}
16-
<div class="tooltip" class:interactive role="tooltip">
89+
<div
90+
class="tooltip"
91+
class:visible
92+
class:interactive
93+
role="tooltip"
94+
bind:this={tooltipEl}
95+
style={posStyle}
96+
>
1797
{#if tooltipContent}
1898
{@render tooltipContent()}
1999
{:else if content}
@@ -30,63 +110,44 @@
30110
}
31111
32112
.tooltip {
33-
position: absolute;
34-
bottom: 100%;
35-
left: 50%;
36-
transform: translateX(-50%);
37-
margin-bottom: 8px;
38-
padding: 0.75rem 1rem;
113+
/* Fixed escapes overflow:hidden on any ancestor (e.g. archive collapse container)
114+
and overflow-x:clip on html/body, so the tooltip is always fully visible. */
115+
position: fixed;
116+
/* left / top set by JS; initial off-screen value prevents flash on first hover */
117+
max-width: 260px;
118+
padding: 0.5rem 0.875rem;
39119
background-color: var(--surface-three);
40120
color: var(--text-four);
41121
border-radius: 12px;
42-
font-size: 16px;
122+
font-size: 0.875rem;
43123
font-weight: 500;
44-
white-space: nowrap;
124+
line-height: 1.5;
125+
text-align: center;
126+
white-space: normal;
127+
word-break: break-word;
45128
box-shadow: var(--drop-shadow-one);
46129
opacity: 0;
47130
visibility: hidden;
48131
transition:
49132
opacity 0.2s ease,
50133
visibility 0.2s ease;
51-
z-index: var(--z-dropdown);
134+
z-index: var(--z-modal);
52135
pointer-events: none;
53136
}
54137
55-
.tooltip::after {
56-
content: '';
57-
position: absolute;
58-
top: 100%;
59-
left: 0;
60-
right: 0;
61-
height: 12px;
138+
.tooltip.visible {
139+
opacity: 1;
140+
visibility: visible;
62141
}
63142
64-
@media (hover: hover) {
65-
.tooltip-wrapper:hover .tooltip {
66-
opacity: 1;
67-
visibility: visible;
68-
}
69-
70-
.tooltip-wrapper:hover .tooltip.interactive {
71-
pointer-events: auto;
72-
}
73-
}
74-
75-
@media (hover: none) {
76-
.tooltip-wrapper:focus-within .tooltip {
77-
opacity: 1;
78-
visibility: visible;
79-
}
80-
81-
.tooltip-wrapper:focus-within .tooltip.interactive {
82-
pointer-events: auto;
83-
}
143+
.tooltip.visible.interactive {
144+
pointer-events: auto;
84145
}
85146
86147
.tooltip :global(a) {
87148
color: var(--primary);
88149
text-decoration: none;
89-
font-size: 17px;
150+
font-size: 0.9375rem;
90151
}
91152
92153
.tooltip :global(a:hover) {
@@ -95,7 +156,7 @@
95156
96157
.tooltip :global(p) {
97158
margin: 0 0 0.25rem 0;
98-
font-size: 17px;
159+
font-size: 0.9375rem;
99160
color: var(--text-four);
100161
opacity: 0.8;
101162
}

src/lib/components/organisms/AnnouncementCard.svelte

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@
2626
let archivedTime = $derived(
2727
announcement.archived_at ? relativeTime(announcement.archived_at) : ''
2828
);
29+
30+
let textContent = $derived(
31+
announcement.content
32+
? announcement.content
33+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
34+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
35+
.replace(/<[^>]*>/g, ' ')
36+
.replace(/\s+/g, ' ')
37+
.trim()
38+
: ''
39+
);
2940
</script>
3041

3142
<a {href} class="card-link" data-sveltekit-preload-data onclick={handleClick}>
@@ -48,15 +59,17 @@
4859
</header>
4960

5061
<footer class="footer">
51-
{#if announcement.content}
62+
{#if textContent}
5263
<div class="content-body">
53-
{@html announcement.content}
64+
{textContent}
5465
</div>
5566
{/if}
5667

5768
{#if announcement.tags && announcement.tags.length > 0}
58-
<hr />
59-
<TagsFilter tags={announcement.tags} clickable={false} />
69+
<div class="tags-wrapper">
70+
<hr />
71+
<TagsFilter tags={announcement.tags} clickable={false} />
72+
</div>
6073
{/if}
6174
</footer>
6275
</div>
@@ -99,7 +112,6 @@
99112
.content {
100113
display: flex;
101114
flex-direction: column;
102-
justify-content: space-between;
103115
gap: 12px;
104116
height: 100%;
105117
padding: 12px 16px;
@@ -134,7 +146,15 @@
134146
}
135147
136148
.footer {
149+
flex: 1;
150+
gap: 12px;
151+
}
152+
153+
.tags-wrapper {
154+
display: flex;
155+
flex-direction: column;
137156
gap: 12px;
157+
margin-top: auto;
138158
}
139159
140160
.content-body {

src/routes/+layout.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
useHolidayTheme();
1717
1818
let publishedAnnouncements = $derived(
19-
(data.latestAnnouncements ?? []).filter(item => !isScheduled(item.created_at))
19+
(data.latestAnnouncements ?? []).filter(item => !isScheduled(item.announcement.created_at))
2020
);
2121
2222
$effect(() => {

0 commit comments

Comments
 (0)