feat: Phases 4-7 — Full Feature Expansion (26 features)

Phase 4 — New Widget Types:
- Clock/Weather, System Stats, RSS/Feed, Calendar, Markdown,
  Metric/Counter, Link Group, Camera/Stream widgets
- Backend services with caching for each data source
- Full creation form with dynamic config fields per type

Phase 5 — Visual & Styling Enhancements:
- Glassmorphism card style (solid/glass/outline)
- Board-level themes with per-board hue/saturation
- Animated SVG status rings replacing static dots
- Card size options (compact/medium/large)
- Custom CSS injection (admin + per-board, sanitized)
- Wallpaper backgrounds with blur/overlay/parallax

Phase 6 — Functional Features:
- Favorites bar with drag-and-drop reordering
- Recent apps tracking with privacy toggle
- Uptime dashboard page (/status, guest-accessible)
- Notifications system (Discord/Slack/Telegram/HTTP webhooks)
- App tags with filtering in board view
- Multi-URL app cards with expandable sub-links
- Personal API tokens with scoped permissions
- Audit log with retention and admin viewer

Phase 7 — Quality of Life:
- Onboarding wizard (5-step first-launch setup)
- App URL health preview with favicon/title detection
- Board templates (4 built-in + custom import/export)
- Keyboard shortcut overlay (j/k nav, 1-9 boards, ? help)

212 files changed, 15641 insertions, 980 deletions.
Build, lint, type check, and 222 tests all pass.
This commit is contained in:
2026-03-25 14:18:10 +03:00
parent 8d7847889e
commit 1c0a7cb850
212 changed files with 15642 additions and 981 deletions
+189
View File
@@ -0,0 +1,189 @@
/**
* RSS/Atom feed service — fetches and parses RSS/Atom feeds.
* Uses lightweight XML parsing without heavy dependencies.
*/
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
const FETCH_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_ITEMS = 10;
interface CacheEntry {
readonly data: readonly FeedItem[];
readonly expiresAt: number;
}
export interface FeedItem {
readonly title: string;
readonly link: string;
readonly pubDate: string;
readonly summary: string;
}
const cache = new Map<string, CacheEntry>();
function getCached(key: string): readonly FeedItem[] | null {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return null;
}
return entry.data;
}
function setCache(key: string, data: readonly FeedItem[]): void {
cache.set(key, {
data,
expiresAt: Date.now() + CACHE_TTL_MS
});
}
/**
* Extract text content between XML tags.
*/
function extractTag(xml: string, tag: string): string {
// Handle CDATA sections
const cdataPattern = new RegExp(
`<${tag}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>\\s*</${tag}>`,
'i'
);
const cdataMatch = xml.match(cdataPattern);
if (cdataMatch) return cdataMatch[1].trim();
// Handle regular content
const pattern = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
const match = xml.match(pattern);
if (match) return match[1].trim();
return '';
}
/**
* Extract href from Atom link tag.
*/
function extractAtomLink(entryXml: string): string {
// Look for link with rel="alternate" or no rel
const altMatch = entryXml.match(/<link[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["']/i);
if (altMatch) return altMatch[1];
const hrefMatch = entryXml.match(/<link[^>]*href=["']([^"']+)["']/i);
if (hrefMatch) return hrefMatch[1];
return '';
}
/**
* Parse RSS 2.0 feed XML.
*/
function parseRss(xml: string, maxItems: number): readonly FeedItem[] {
const items: FeedItem[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/gi;
let match: RegExpExecArray | null;
while ((match = itemRegex.exec(xml)) !== null && items.length < maxItems) {
const itemXml = match[1];
items.push({
title: extractTag(itemXml, 'title') || 'Untitled',
link: extractTag(itemXml, 'link') || '',
pubDate: extractTag(itemXml, 'pubDate') || '',
summary: extractTag(itemXml, 'description') || ''
});
}
return items;
}
/**
* Parse Atom feed XML.
*/
function parseAtom(xml: string, maxItems: number): readonly FeedItem[] {
const items: FeedItem[] = [];
const entryRegex = /<entry>([\s\S]*?)<\/entry>/gi;
let match: RegExpExecArray | null;
while ((match = entryRegex.exec(xml)) !== null && items.length < maxItems) {
const entryXml = match[1];
items.push({
title: extractTag(entryXml, 'title') || 'Untitled',
link: extractAtomLink(entryXml) || '',
pubDate: extractTag(entryXml, 'published') || extractTag(entryXml, 'updated') || '',
summary: extractTag(entryXml, 'summary') || extractTag(entryXml, 'content') || ''
});
}
return items;
}
/**
* Strip HTML tags from a string (for summaries).
*/
function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
}
/**
* Fetch and parse an RSS or Atom feed from a URL.
*/
export async function fetchFeed(feedUrl: string, maxItems?: number): Promise<readonly FeedItem[]> {
const limit = maxItems ?? DEFAULT_MAX_ITEMS;
const cacheKey = `${feedUrl}:${limit}`;
const cached = getCached(cacheKey);
if (cached) return cached;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(feedUrl, {
signal: controller.signal,
headers: {
'User-Agent': 'WebAppLauncher/1.0',
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml'
}
});
if (!response.ok) {
throw new Error(`Feed returned ${response.status}`);
}
const xml = await response.text();
// Detect feed type and parse
let items: readonly FeedItem[];
if (xml.includes('<feed') && xml.includes('xmlns="http://www.w3.org/2005/Atom"')) {
items = parseAtom(xml, limit);
} else {
items = parseRss(xml, limit);
}
// Strip HTML from summaries
const cleanItems = items.map((item) => ({
...item,
summary: stripHtml(item.summary).substring(0, 500)
}));
setCache(cacheKey, cleanItems);
return cleanItems;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('Feed request timed out');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Clear the RSS feed cache.
*/
export function clearCache(): void {
cache.clear();
}