diff --git a/README.md b/README.md index c520e33..dc98216 100644 --- a/README.md +++ b/README.md @@ -1,165 +1,89 @@ # nipsys.dev -A personal portfolio website featuring a terminal emulator interface, deployed -on IPFS. +My personal portfolio website with a terminal-style interface. Navigate using +CLI commands instead of traditional UI. ![License](https://img.shields.io/badge/license-GPL--3.0--or--later-blue) ![Node](https://img.shields.io/badge/node-%3E%3D22.0.0-green) ![pnpm](https://img.shields.io/badge/pnpm-%3E%3D9-orange) -## Overview +**Live:** [nipsys.eth.limo](https://nipsys.eth.limo) -This is my personal website. The site features a novel terminal-style interface -where visitors can navigate using CLI commands rather than traditional UI -elements. +## What makes it unique -**Live Access:** +- **Terminal interface** — Type commands like `whoami`, `resume`, `status`, + `contact` +- **Peer-to-peer data retrieval** — Real-time service health and recent Pixelfed + posts fetch via Logos Delivery (A censorship resistant P2P messaging network) +- **IPFS hosted** — Deployed on IPFS with IPNS, accessible via ENS -- [nipsys.eth.limo](https://nipsys.eth.limo) -- [IPNS Gateway](https://k2k4r8ng8uzrtqb5ham8kao889m8qezu96z4w3lpinyqghum43veb6n3.ipns.dweb.link/) - (note: no proper caching, expect timeouts) +## Commands -## Features +| Command | What it does | +| --------- | -------------------------------------- | +| `welcome` | Introduction | +| `whoami` | About me — bio, skills, GPG key | +| `resume` | View/download my CV | +| `contact` | Email, social links, messaging | +| `status` | Live health of my self-hosted services | +| `gallery` | Recent photos from Pixelfed | +| `help` | List all commands | -- **Terminal Interface** - Navigate the site using CLI commands (`help`, - `whoami`, `contact`, `status`, etc.) -- **Decentralized Status** - Real-time service health monitoring via Logos - Delivery (previously named Waku, a P2P communication network) -- **IPFS Deployment** - Hosted on IPFS with IPNS for mutable content -- **Internationalization** - Available in English and French -- **Dark/Light Theme** - System preference detection with manual toggle -- **Keyboard Navigation** - Full keyboard support with history, autocomplete, - and shortcuts +**Keyboard:** `Tab` for autocomplete, `↑↓` for history, `Ctrl+C` to cancel. ## Tech Stack -| Category | Technology | -| ------------- | ---------------------------------------------------------------------------------------------------- | -| Framework | [Next.js 15](https://nextjs.org/) (App Router, Static Export) | -| UI | [React 19](https://react.dev/), [Tailwind CSS 4](https://tailwindcss.com/) | -| Components | [@nipsys/lsd](https://github.com/nipsysdev/lsd), [@phosphor-icons/react](https://phosphoricons.com/) | -| State | [Nanostores](https://github.com/nanostores/nanostores) | -| i18n | [next-intl](https://next-intl-docs.vercel.app/) | -| Decentralized | [@waku/sdk](https://waku.org/), [@helia/verified-fetch](https://helia.io/) | -| Testing | [Vitest](https://vitest.dev/), [Testing Library](https://testing-library.com/) | -| Linting | [Biome](https://biomejs.dev/) | -| Git Hooks | [Lefthook](https://github.com/evilmartians/lefthook) | +| Layer | Tools | +| --------- | ------------------------------------ | +| Framework | Next.js 15 (static export), React 19 | +| Styling | Tailwind CSS 4, @nipsys/lsd | +| State | Nanostores | +| i18n | next-intl (EN/FR) | +| P2P | @waku/sdk (Logos Delivery) | +| Testing | Vitest | ## Getting Started -### Prerequisites - -- Node.js >= 22.0.0 -- pnpm >= 9 - -### Installation +**Requirements:** Node.js ≥22, pnpm ≥9 ```bash -# Clone the repository git clone https://github.com/nipsysdev/site.git cd site - -# Initialize submodules (for resume PDFs) -git submodule update --init --recursive - -# Install dependencies +git submodule update --init --recursive # Resume PDFs pnpm install - -# Start development server pnpm dev ``` -Open [localhost:3000](http://localhost:3000) to view the site. - -### Available Scripts - -| Script | Description | -| -------------------- | -------------------------------- | -| `pnpm dev` | Start dev server with Turbopack | -| `pnpm build` | Production build (static export) | -| `pnpm serve` | Serve the static output locally | -| `pnpm test:unit` | Run unit tests | -| `pnpm test:coverage` | Run tests with coverage report | -| `pnpm lint` | Lint codebase | -| `pnpm lint:fix` | Lint and auto-fix issues | -| `pnpm format` | Format code | -| `pnpm typecheck` | TypeScript type check | -| `pnpm analyze` | Build with bundle analyzer | - -## Terminal Commands - -The site uses a terminal emulator interface. Available commands: - -| Command | Description | -| ------------ | --------------------------------------- | -| `welcome` | Display welcome message | -| `whoami` | About me - bio, badges, GPG fingerprint | -| `resume` | View/download resume (EN/FR) | -| `contact` | Contact information and social links | -| `status` | Self-hosted services health status | -| `help` | List available commands | -| `clear` | Clear terminal screen | -| `build-info` | Build timestamp and deployment info | - -### Keyboard Shortcuts - -| Key | Action | -| --------- | ------------------------ | -| `Enter` | Execute command | -| `Tab` | Autocomplete | -| `↑` / `↓` | Navigate command history | -| `Ctrl+C` | Cancel current input | - -## Project Structure +Open [localhost:3000](http://localhost:3000). -``` -site/ -├── src/ -│ ├── app/ # Next.js App Router pages -│ │ └── [locale]/ # Internationalized routes (en, fr) -│ ├── components/ # React components -│ │ ├── terminal/ # Terminal emulator -│ │ ├── cmd-outputs/ # Command output components -│ │ └── layout/ # Layout components -│ ├── lib/ -│ │ └── dpulse/ # Waku P2P status monitoring -│ ├── stores/ # Nanostores state -│ ├── hooks/ # Custom React hooks -│ ├── i18n/ # Internationalization config -│ ├── constants/ # App constants -│ └── utils/ # Utility functions -├── out/ # Static export output -└── .github/workflows/ # CI/CD pipelines -``` +## Scripts -## Architecture +| Command | Description | +| ---------------- | ------------------------- | +| `pnpm dev` | Development server | +| `pnpm build` | Production build → `out/` | +| `pnpm test:unit` | Run tests | +| `pnpm lint` | Lint with Biome | -### Static Export - -The site is built as a fully static export using Next.js's `output: 'export'` -configuration. This enables: - -- IPFS/IPNS deployment -- No server-side dependencies -- Fast, cacheable content delivery - -### dpulse - Decentralized Status Monitoring +## Architecture -The `status` command connects to the -[dpulse](https://github.com/nipsysdev/dpulse) system via the Waku P2P network: +``` +src/ +├── app/[locale]/ # Pages (en, fr) +├── components/ +│ ├── terminal/ # Terminal emulator +│ └── cmd-outputs/ # Command output components +├── lib/dpulse/ # Logos Delivery integration +└── stores/ # Nanostores state +``` -- Real-time service health updates via filter protocol -- Historical messages via store protocol -- Ed25519 signature verification for authenticity -- IPFS-hosted service icons via Helia +**dpulse** connects to [dpulse](https://github.com/nipsysdev/dpulse) via Waku +for real-time service status with Ed25519 signature verification. ## Deployment -The site is automatically deployed to IPFS via GitHub Actions: - -1. On push to main: CI pipeline runs lint, tests, and build -2. Static export is published to IPNS -3. Pinned to 4EVERLand for availability +Push to `main` triggers GitHub Actions: lint → test → build → publish to IPNS → +pin to 4EVERLand. ## License diff --git a/docs/pixelfed-feed-consumption.md b/docs/pixelfed-feed-consumption.md new file mode 100644 index 0000000..80c868d --- /dev/null +++ b/docs/pixelfed-feed-consumption.md @@ -0,0 +1,521 @@ +# Pixelfed Feed Consumption - Implementation Guide + +**Created:** 2026-06-10 +**Status:** Ready for Implementation +**Related:** `docs/pixelfed-integration.md`, `dpulse/docs/pixelfed-feed-integration.md` + +--- + +## Overview + +The site consumes Pixelfed feed data published by dpulse via Waku. This document explains how to receive, decode, and display feed entries. + +**Data Flow:** +1. dpulse fetches Atom feed → encodes as FeedBatch protobuf → publishes to Waku +2. Site subscribes via Waku Filter → receives FeedBatch → decodes → verifies signature +3. Site queries Waku Store for historical messages on startup +4. UI components reactively update via nanostores + +--- + +## Protobuf Schema + +### Types to Define + +Copy the FeedBatch schema from dpulse. Define in `src/lib/dpulse/protobuf/feed-schema.ts`: + +```typescript +import type { Type } from 'protobufjs'; +import protobuf from 'protobufjs'; + +export interface FeedImage { + url: string; + mimeType: string; +} + +export interface FeedEntry { + id: string; + title: string; + link: string; + content?: string; + author?: string; + published?: number; + images: FeedImage[]; +} + +export interface FeedBatch { + source: string; + fetchedAt: number; + entries: FeedEntry[]; + signature?: string; +} + +const FeedImageType = new protobuf.Type('FeedImage') + .add(new protobuf.Field('url', 1, 'string')) + .add(new protobuf.Field('mimeType', 2, 'string')); + +const FeedEntryType = new protobuf.Type('FeedEntry') + .add(new protobuf.Field('id', 1, 'string')) + .add(new protobuf.Field('title', 2, 'string')) + .add(new protobuf.Field('link', 3, 'string')) + .add(new protobuf.Field('content', 4, 'string', 'optional')) + .add(new protobuf.Field('author', 5, 'string', 'optional')) + .add(new protobuf.Field('published', 6, 'int64', 'optional')) + .add(new protobuf.Field('images', 7, 'FeedImage', 'repeated')); + +const FeedBatchType = new protobuf.Type('FeedBatch') + .add(new protobuf.Field('source', 1, 'string')) + .add(new protobuf.Field('fetchedAt', 2, 'int64')) + .add(new protobuf.Field('entries', 3, 'FeedEntry', 'repeated')) + .add(new protobuf.Field('signature', 4, 'string', 'optional')); + +const root = new protobuf.Root() + .define('dpulse') + .add(FeedImageType) + .add(FeedEntryType) + .add(FeedBatchType); + +export const FeedBatch = root.lookupType( + 'dpulse.FeedBatch', +) as unknown as Type; +``` + +**Field Numbers (from dpulse/src/protobuf/schema.ts):** +- `FeedImage`: `url=1`, `mimeType=2` +- `FeedEntry`: `id=1`, `title=2`, `link=3`, `content=4`, `author=5`, `published=6`, `images=7` +- `FeedBatch`: `source=1`, `fetchedAt=2`, `entries=3`, `signature=4` + +--- + +## Feed Codec + +Create `src/lib/dpulse/protobuf/feed-codec.ts`: + +```typescript +import { + base64ToSignature, + importPublicKey, + verifyMessage, +} from '../crypto/signature'; +import type { FeedBatch as FeedBatchType } from './feed-schema'; +import { FeedBatch } from './feed-schema'; + +export function decodeFeedBatch(bytes: Uint8Array): FeedBatchType { + const decoded = FeedBatch.decode(bytes); + return FeedBatch.toObject(decoded, { + longs: Number, + enums: Number, + bytes: Uint8Array, + }) as unknown as FeedBatchType; +} + +export function validateFeedBatch(batch: FeedBatchType): boolean { + if (!batch.source || typeof batch.source !== 'string') { + console.error('FeedBatch validation failed: missing or invalid source'); + return false; + } + + if (!batch.fetchedAt || typeof batch.fetchedAt !== 'number') { + console.error('FeedBatch validation failed: missing or invalid fetchedAt'); + return false; + } + + if (!Array.isArray(batch.entries)) { + console.error('FeedBatch validation failed: missing or invalid entries'); + return false; + } + + for (const entry of batch.entries) { + if (!entry.id || typeof entry.id !== 'string') { + console.error('FeedBatch validation failed: entry missing id'); + return false; + } + if (!entry.title || typeof entry.title !== 'string') { + console.error('FeedBatch validation failed: entry missing title'); + return false; + } + if (!entry.link || typeof entry.link !== 'string') { + console.error('FeedBatch validation failed: entry missing link'); + return false; + } + } + + if (!batch.signature || typeof batch.signature !== 'string') { + console.error('FeedBatch validation failed: missing signature'); + return false; + } + + return true; +} + +function serializeEntryForSigning(entry: FeedEntry): string { + const imagesStr = entry.images + .map((img) => `${img.url}:${img.mimeType}`) + .join(','); + return `${entry.id}:${entry.title}:${entry.link}:${entry.content || ''}:${entry.author || ''}:${entry.published?.toString() || ''}:${imagesStr}`; +} + +export async function validateAndDecodeFeedBatch( + bytes: Uint8Array, + publicKeyPem: string, +): Promise { + const batch = decodeFeedBatch(bytes); + + if (!validateFeedBatch(batch)) { + return null; + } + + try { + const publicKey = await importPublicKey(publicKeyPem); + + // Reconstruct the payload exactly as dpulse does + const entriesStr = batch.entries.map(serializeEntryForSigning).join('|'); + const payload = `${batch.source}:${entriesStr}:${batch.fetchedAt.toString()}`; + const payloadBytes = new TextEncoder().encode(payload); + + const signature = base64ToSignature(batch.signature as string); + + const isValid = await verifyMessage(payloadBytes, signature, publicKey); + + if (!isValid) { + console.error( + 'FeedBatch verification failed: invalid signature for', + batch.source, + ); + return null; + } + + return batch; + } catch (error) { + console.error('FeedBatch verification error:', error); + return null; + } +} +``` + +--- + +## Content Topic Configuration + +Add to `src/lib/dpulse/config.ts`: + +```typescript +export const DPULSE_CONFIG = { + contentTopic: '/dpulse_site/1.0.0/prod/proto', + feedContentTopic: '/pixelfed_feed/1.0.0/prod/proto', + publicKey: `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAt2CsQmm+c5L0UQmpkowqQ1WqTcFHsv6kZhI508t1sXU= +-----END PUBLIC KEY-----`, + userAgent: 'dev.nipsys.site', +} as const; +``` + +--- + +## State Management + +Add to `src/lib/dpulse/stores.ts`: + +```typescript +import { atom } from 'nanostores'; +import type { ConnectionStatus, ServiceStatus } from './types'; +import type { FeedEntry } from './protobuf/feed-schema'; + +export const $connectionStatus = atom('disconnected'); +export const $statusMessages = atom>(new Map()); +export const $isLoading = atom(false); +export const $error = atom(null); +export const $peerCount = atom(0); + +// Feed stores +export const $feedEntries = atom([]); +export const $feedLastFetched = atom(null); +``` + +--- + +## Waku Integration + +### Option A: Extend Existing Clients + +Modify `waku-filter-client.ts` to support multiple content topics: + +```typescript +import type { IDecodedMessage, IDecoder, LightNode } from '@waku/sdk'; +import { DPULSE_CONFIG } from './constants'; +import { setError } from './manager'; +import { processMessagePayload } from './message-processor'; +import { processFeedBatch } from './feed-processor'; + +let statusDecoder: IDecoder | null = null; +let feedDecoder: IDecoder | null = null; + +export function getStatusDecoder(): IDecoder | null { + return statusDecoder; +} + +export function getFeedDecoder(): IDecoder | null { + return feedDecoder; +} + +export async function setupFilterSubscription(node: LightNode): Promise { + statusDecoder = node.createDecoder({ contentTopic: DPULSE_CONFIG.contentTopic }); + feedDecoder = node.createDecoder({ contentTopic: DPULSE_CONFIG.feedContentTopic }); + + console.log('[dpulse] Subscribing to status topic:', DPULSE_CONFIG.contentTopic); + console.log('[dpulse] Subscribing to feed topic:', DPULSE_CONFIG.feedContentTopic); + + await node.filter.subscribe([statusDecoder], handleStatusMessage); + await node.filter.subscribe([feedDecoder], handleFeedMessage); + + console.log('[dpulse] Successfully subscribed to all topics'); +} + +async function handleStatusMessage(wakuMessage: IDecodedMessage): Promise { + try { + const payload = wakuMessage.payload; + if (!payload || payload.length === 0) return; + await processMessagePayload(payload, 'filter'); + } catch (error) { + console.error('[dpulse] Error handling status message:', error); + setError(error instanceof Error ? error.message : 'Unknown error'); + } +} + +async function handleFeedMessage(wakuMessage: IDecodedMessage): Promise { + try { + const payload = wakuMessage.payload; + if (!payload || payload.length === 0) return; + await processFeedBatch(payload, 'filter'); + } catch (error) { + console.error('[dpulse] Error handling feed message:', error); + } +} +``` + +### Option B: Create Feed-Specific Client + +Create `src/lib/dpulse/feed-client.ts`: + +```typescript +import type { IDecodedMessage, IDecoder, LightNode } from '@waku/sdk'; +import { DPULSE_CONFIG, STORE_HISTORY_HOURS } from './constants'; +import { validateAndDecodeFeedBatch } from './protobuf/feed-codec'; +import { $feedEntries, $feedLastFetched } from './stores'; + +let feedDecoder: IDecoder | null = null; + +export function getFeedDecoder(): IDecoder | null { + return feedDecoder; +} + +export async function setupFeedSubscription(node: LightNode): Promise { + feedDecoder = node.createDecoder({ contentTopic: DPULSE_CONFIG.feedContentTopic }); + + console.log('[dpulse] Subscribing to feed topic:', DPULSE_CONFIG.feedContentTopic); + await node.filter.subscribe([feedDecoder], handleFeedMessage); + console.log('[dpulse] Successfully subscribed to feed messages'); +} + +async function handleFeedMessage(wakuMessage: IDecodedMessage): Promise { + try { + const payload = wakuMessage.payload; + if (!payload || payload.length === 0) return; + await processFeedBatch(payload, 'filter'); + } catch (error) { + console.error('[dpulse] Error handling feed message:', error); + } +} + +export async function processFeedBatch( + payload: Uint8Array, + source: 'filter' | 'store', +): Promise { + const batch = await validateAndDecodeFeedBatch( + payload, + DPULSE_CONFIG.publicKey, + ); + + if (!batch) return false; + + const currentEntries = $feedEntries.get(); + const existingIds = new Set(currentEntries.map((e) => e.id)); + + const newEntries = batch.entries.filter((e) => !existingIds.has(e.id)); + + if (newEntries.length > 0) { + $feedEntries.set([...newEntries, ...currentEntries].slice(0, 100)); + } + + const lastFetched = $feedLastFetched.get(); + if (!lastFetched || batch.fetchedAt > lastFetched) { + $feedLastFetched.set(batch.fetchedAt); + } + + console.log( + `[dpulse] Processed FeedBatch from ${source}: ${batch.entries.length} entries, ${newEntries.length} new`, + ); + return true; +} + +export async function queryFeedHistory(node: LightNode): Promise { + const storeDecoder = node.createDecoder({ + contentTopic: DPULSE_CONFIG.feedContentTopic, + }); + + const now = new Date(); + const startTime = new Date(now.getTime() - STORE_HISTORY_HOURS * 3600000); + + console.log( + `[dpulse] Querying Store for feed messages from ${startTime.toISOString()} to ${now.toISOString()}`, + ); + + const decodePromises: Promise[] = []; + + try { + await node.store.queryWithOrderedCallback( + [storeDecoder], + (wakuMessage) => { + const payload = wakuMessage.payload; + if (!payload || payload.length === 0) return false; + + if (wakuMessage.timestamp) { + const msgTime = wakuMessage.timestamp; + if (msgTime < startTime || msgTime > now) return false; + } + + decodePromises.push(processFeedBatch(payload, 'store')); + return false; + }, + { + paginationForward: false, + timeStart: startTime, + timeEnd: now, + }, + ); + + const results = await Promise.all(decodePromises); + const messageCount = results.filter(Boolean).length; + + console.log(`[dpulse] Feed Store query complete, processed ${messageCount} batches`); + return messageCount; + } catch (error) { + console.error('[dpulse] Feed Store query failed:', error); + return 0; + } +} +``` + +--- + +## Component Updates + +### Update PixelFedGallery.tsx + +Replace ISR fetch with store subscription: + +```typescript +'use client'; + +import { Badge, Card, CardContent, Typography } from '@nipsys/lsd'; +import { ArrowSquareOutIcon } from '@phosphor-icons/react'; +import Image from 'next/image'; +import { useStore } from '@nanostores/react'; +import { $feedEntries, $feedLastFetched, $isLoading, $error } from '@/lib/dpulse/stores'; + +export default function PixelFedGallery() { + const entries = useStore($feedEntries); + const lastFetched = useStore($feedLastFetched); + const isLoading = useStore($isLoading); + const error = useStore($error); + + if (isLoading && entries.length === 0) { + return ( + + Loading gallery... + + ); + } + + if (error && entries.length === 0) { + return ( + + {error} + + ); + } + + if (entries.length === 0) { + return ( + + No posts found. + + ); + } + + return ( +
+ {entries.map((entry) => ( + + + {entry.images.length > 0 && ( + {entry.title} + )} +
+ + {entry.title || entry.content || 'Untitled'} + +
+ + {entry.published + ? new Date(entry.published).toLocaleDateString() + : 'Unknown date'} + + + + +
+
+
+
+ ))} +
+ ); +} +``` + +--- + +## Implementation Checklist + +- [ ] Create `src/lib/dpulse/protobuf/feed-schema.ts` +- [ ] Create `src/lib/dpulse/protobuf/feed-codec.ts` +- [ ] Add `feedContentTopic` to `config.ts` +- [ ] Add feed stores to `stores.ts` +- [ ] Create `feed-client.ts` or extend existing Waku clients +- [ ] Initialize feed subscription in `manager.ts` +- [ ] Update `PixelFedGallery.tsx` to use stores +- [ ] Remove or keep old ISR fetch from `src/lib/pixelfed.ts` as fallback + +--- + +## Notes + +- **Signature verification**: Uses the same Ed25519 public key as status messages (defined in `DPULSE_CONFIG.publicKey`) +- **Batch limits**: Feed batches are limited to 20 entries, published every 30 minutes +- **Deduplication**: Handle duplicate entries (same `id`) by filtering in `processFeedBatch` +- **Store limit**: Consider capping stored entries (e.g., 100) to prevent memory issues +- **Timestamp display**: Use `$feedLastFetched` to show "last updated" in UI +- **Fallback**: Keep existing ISR fetch in `src/lib/pixelfed.ts` as a fallback if Waku is unavailable diff --git a/package.json b/package.json index cad1b65..28b084d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@phosphor-icons/react": "^2.1.7", "@waku/sdk": "^0.0.36", "dayjs": "^1.11.20", + "fast-xml-parser": "^5.8.0", "nanostores": "^1.1.1", "next": "^15.5.12", "next-intl": "^4.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 463cc67..e1988bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: dayjs: specifier: ^1.11.20 version: 1.11.20 + fast-xml-parser: + specifier: ^5.8.0 + version: 5.8.0 nanostores: specifier: ^1.1.1 version: 1.3.0 @@ -1008,6 +1011,9 @@ packages: '@noble/secp256k1@1.7.2': resolution: {integrity: sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==} + '@nodable/entities@2.1.1': + resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3065,6 +3071,13 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.8.0: + resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4093,6 +4106,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4507,6 +4524,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + strtok3@10.3.5: resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} engines: {node: '>=18'} @@ -4937,6 +4957,10 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -6384,6 +6408,8 @@ snapshots: '@noble/secp256k1@1.7.2': {} + '@nodable/entities@2.1.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8492,6 +8518,19 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.8.0: + dependencies: + '@nodable/entities': 2.1.1 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + xml-naming: 0.1.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -9738,6 +9777,8 @@ snapshots: parseurl@1.3.3: {} + path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} path-scurry@1.11.1: @@ -10301,6 +10342,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.3.0: {} + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 @@ -10700,6 +10743,8 @@ snapshots: xml-name-validator@5.0.0: {} + xml-naming@0.1.0: {} + xml2js@0.6.2: dependencies: sax: 1.6.0 diff --git a/src/app/[locale]/contact/page.tsx b/src/app/[locale]/contact/page.tsx index 6983853..e1531e3 100644 --- a/src/app/[locale]/contact/page.tsx +++ b/src/app/[locale]/contact/page.tsx @@ -1,4 +1,4 @@ -import Contact from '@/components/contact/Contact'; +import ContactOutput from '@/components/cmd-outputs/ContactOutput'; import StaticOutput from '@/components/StaticOutput'; import TerminalEmulator from '@/components/terminal/TerminalEmulator'; import type { RouteData } from '@/types/routing'; @@ -17,7 +17,7 @@ export default async function ContactPage({ params }: ContactPageProps) { return ( <> - + diff --git a/src/app/[locale]/gallery/page.tsx b/src/app/[locale]/gallery/page.tsx new file mode 100644 index 0000000..c19e760 --- /dev/null +++ b/src/app/[locale]/gallery/page.tsx @@ -0,0 +1,25 @@ +import GalleryOutput from '@/components/cmd-outputs/GalleryOutput'; +import StaticOutput from '@/components/StaticOutput'; +import TerminalEmulator from '@/components/terminal/TerminalEmulator'; +import type { RouteData } from '@/types/routing'; +import { Command } from '@/types/terminal'; +import { setPageMeta } from '@/utils/metadata-utils'; + +interface GalleryPageProps { + params: Promise<{ locale: string }>; +} + +export const generateMetadata = async (routeData: RouteData) => + await setPageMeta(routeData, 'gallery'); + +export default async function GalleryPage({ params }: GalleryPageProps) { + await params; + return ( + <> + + + + + + ); +} diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index bf9abab..cea0979 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -9,20 +9,20 @@ interface HomePageProps { export default async function HomePage({ params }: HomePageProps) { const { locale } = await params; - const t = await getTranslations({ locale, namespace: 'Terminal' }); + const t = await getTranslations({ locale, namespace: 'Welcome' }); return ( <>

- {t.rich('cmds.welcome.welcome', { + {t.rich('welcome', { name: (name) => name, })}

-

{t('cmds.welcome.site_intro_1')}

+

{t('siteIntro1')}

- {t.rich('cmds.welcome.site_intro_2', { + {t.rich('siteIntro2', { cmd: () => 'help', })}

diff --git a/src/app/[locale]/resume/page.tsx b/src/app/[locale]/resume/page.tsx index 34acb8a..0ada4dd 100644 --- a/src/app/[locale]/resume/page.tsx +++ b/src/app/[locale]/resume/page.tsx @@ -1,6 +1,4 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import Resume from '@/components/resume/Resume'; +import ResumeOutput from '@/components/cmd-outputs/ResumeOutput'; import StaticOutput from '@/components/StaticOutput'; import TerminalEmulator from '@/components/terminal/TerminalEmulator'; import type { RouteData } from '@/types/routing'; @@ -14,27 +12,12 @@ interface ResumePageProps { export const generateMetadata = async (routeData: RouteData) => await setPageMeta(routeData, 'resume'); -async function getResumeHtml() { - const publicDir = join(process.cwd(), 'public', 'resume'); - const [htmlEn, htmlFr] = await Promise.all([ - readFile(join(publicDir, 'Xavier-SALINIERE_resume.EN.html'), 'utf-8').catch( - () => '', - ), - readFile(join(publicDir, 'Xavier-SALINIERE_resume.FR.html'), 'utf-8').catch( - () => '', - ), - ]); - return { htmlEn, htmlFr }; -} - export default async function ResumePage({ params }: ResumePageProps) { await params; - const { htmlEn, htmlFr } = await getResumeHtml(); - return ( <> - + diff --git a/src/app/[locale]/status/page.tsx b/src/app/[locale]/status/page.tsx index 52922c6..3460028 100644 --- a/src/app/[locale]/status/page.tsx +++ b/src/app/[locale]/status/page.tsx @@ -1,4 +1,5 @@ import { getTranslations } from 'next-intl/server'; +import StatusOutput from '@/components/cmd-outputs/StatusOutput'; import StaticOutput from '@/components/StaticOutput'; import TerminalEmulator from '@/components/terminal/TerminalEmulator'; import type { RouteData } from '@/types/routing'; @@ -14,15 +15,12 @@ export const generateMetadata = async (routeData: RouteData) => export default async function StatusPage({ params }: StatusPageProps) { const { locale } = await params; - const t = await getTranslations({ locale, namespace: 'Terminal' }); + const _t = await getTranslations({ locale, namespace: 'Terminal' }); return ( <> -
-

{t('cmds.status.title')}

-

{t('cmds.status.description')}

-
+
diff --git a/src/app/[locale]/whoami/page.tsx b/src/app/[locale]/whoami/page.tsx index bd3f146..038b27b 100644 --- a/src/app/[locale]/whoami/page.tsx +++ b/src/app/[locale]/whoami/page.tsx @@ -1,4 +1,4 @@ -import AboutMe from '@/components/about-me/AboutMe'; +import WhoamiOutput from '@/components/cmd-outputs/WhoamiOutput'; import StaticOutput from '@/components/StaticOutput'; import TerminalEmulator from '@/components/terminal/TerminalEmulator'; import type { RouteData } from '@/types/routing'; @@ -17,7 +17,7 @@ export default async function WhoamiPage({ params }: WhoamiPageProps) { return ( <> - + diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 49d13cd..34a5bc3 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -1,7 +1,6 @@ 'use client'; import { useStore } from '@nanostores/react'; -import { useEffect, useState } from 'react'; -import { initWaku } from '@/lib/dpulse/manager'; +import { useEffect } from 'react'; import { $isDarkMode } from '@/stores/theme-store'; export default function ThemeProvider({ @@ -10,14 +9,6 @@ export default function ThemeProvider({ children: React.ReactNode; }) { const isDarkMode = useStore($isDarkMode); - const [wakuInitialized, setWakuInitialized] = useState(false); - - useEffect(() => { - if (!wakuInitialized) { - setWakuInitialized(true); - initWaku(); - } - }, [wakuInitialized]); useEffect(() => { const root = document.documentElement; diff --git a/src/components/about-me/AboutMe.tsx b/src/components/about-me/AboutMe.tsx deleted file mode 100644 index a8c512c..0000000 --- a/src/components/about-me/AboutMe.tsx +++ /dev/null @@ -1,114 +0,0 @@ -'use client'; - -import { - Avatar, - AvatarFallback, - AvatarImage, - Badge, - Button, - Typography, -} from '@nipsys/lsd'; -import { - BriefcaseIcon, - CopyIcon, - LockKeyIcon, - MapPinIcon, - TargetIcon, -} from '@phosphor-icons/react'; -import { useTranslations } from 'next-intl'; -import gpgFingerprint from '@/assets/gpg-fingerprint.json'; -import profileImage from '@/assets/Pro-Hacked.webp'; -import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; - -const GPG_FINGERPRINT = gpgFingerprint.fingerprint; - -export default function AboutMe() { - const t = useTranslations('AboutMe'); - const { copyWithToast, isCopied } = useCopyToClipboard(); - - const quickInfo = [ - { icon: MapPinIcon, textKey: 'badges.location' }, - { icon: BriefcaseIcon, textKey: 'badges.experience' }, - { icon: LockKeyIcon, textKey: 'badges.focus' }, - { icon: TargetIcon, textKey: 'badges.goal' }, - ]; - - return ( -
-
- - - X - - -
-
- {t('name')} - - {t('tagline')} - -
- -
- {quickInfo.map(({ icon: Icon, textKey }) => ( - } - > - {t(textKey)} - - ))} -
-
-
- -
- {t('bio.intro1')} - {t('bio.intro2')} - - - {t('bio.para1')} - - - - {t('bio.para2')} - - - - {t('bio.para3')} - - - - {t('bio.para4')} - - - - {t('bio.para5')} - - - {t('bio.current')} -
- -
-
- - {t('gpgLabel')} - - -
- - {GPG_FINGERPRINT} - -
-
- ); -} diff --git a/src/components/cmd-outputs/BuildInfoOutput.tsx b/src/components/cmd-outputs/BuildInfoOutput.tsx index 0fe4eff..dc1005d 100644 --- a/src/components/cmd-outputs/BuildInfoOutput.tsx +++ b/src/components/cmd-outputs/BuildInfoOutput.tsx @@ -1,22 +1,22 @@ -import type { CommandOutputProps } from '@/types/terminal'; +import { useTranslations } from 'next-intl'; -export default function BuildInfoOutput({ t }: CommandOutputProps) { +export default function BuildInfoOutput() { + const t = useTranslations('BuildInfo'); const buildTimestamp = process.env.BUILD_TIMESTAMP; const ipnsHash = process.env.IPNS_HASH; return (
-
{t('cmds.build-info.title')}
+
{t('title')}
- {t('cmds.build-info.timeLabel')}{' '} + {t('timeLabel')}{' '} {buildTimestamp ? new Date(buildTimestamp).toLocaleString() - : t('cmds.build-info.unknown')} + : t('unknown')}
- {t('cmds.build-info.ipnsLabel')}{' '} - {ipnsHash || t('cmds.build-info.notConfigured')} + {t('ipnsLabel')} {ipnsHash || t('notConfigured')}
diff --git a/src/components/cmd-outputs/ContactOutput.tsx b/src/components/cmd-outputs/ContactOutput.tsx index a9c0f33..3a61fd9 100644 --- a/src/components/cmd-outputs/ContactOutput.tsx +++ b/src/components/cmd-outputs/ContactOutput.tsx @@ -1,9 +1,188 @@ -import { Component } from 'react'; -import type { CommandOutputProps } from '@/types/terminal'; -import Contact from '../contact/Contact'; - -export default class ContactOutput extends Component { - render() { - return ; - } +'use client'; + +import { Badge, Button, Typography } from '@nipsys/lsd'; +import { + ButterflyIcon, + ChatTeardropTextIcon, + CopyIcon, + EnvelopeIcon, + GithubLogoIcon, + type IconWeight, + InstagramLogoIcon, + LinkedinLogoIcon, + PaperPlaneTiltIcon, + TwitterLogoIcon, +} from '@phosphor-icons/react'; +import { useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; +import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; + +const ENCODED_EMAIL = 'Ym9uam91ckB4YXZpZXJzLnNo'; + +interface ContactLink { + icon: React.ComponentType<{ weight?: IconWeight; size?: number }>; + labelKey: string; + href: string; + displayText: string; + copyable?: boolean; +} + +export default function ContactOutput() { + const t = useTranslations('Contact'); + const [email, setEmail] = useState(null); + const { copyWithToast, isCopied } = useCopyToClipboard(); + + useEffect(() => { + setEmail(atob(ENCODED_EMAIL)); + }, []); + + const socialLinks: ContactLink[] = [ + { + icon: GithubLogoIcon, + labelKey: 'github', + href: 'https://github.com/nipsysdev', + displayText: 'nipsysdev', + }, + { + icon: TwitterLogoIcon, + labelKey: 'twitter', + href: 'https://x.com/nipsysdev', + displayText: '@nipsysdev', + }, + { + icon: PaperPlaneTiltIcon, + labelKey: 'telegram', + href: 'https://t.me/nipsysdev', + displayText: '@nipsysdev', + }, + { + icon: ButterflyIcon, + labelKey: 'bluesky', + href: 'https://bsky.app/profile/nipsys.bsky.social', + displayText: '@nipsys.bsky.social', + }, + { + icon: InstagramLogoIcon, + labelKey: 'pixelfed', + href: 'https://pixelfed.social/xaviers', + displayText: 'xaviers', + }, + { + icon: LinkedinLogoIcon, + labelKey: 'linkedin', + href: 'https://linkedin.com/in/xaviersaliniere', + displayText: 'xaviersaliniere', + }, + ]; + + const directLinks: ContactLink[] = [ + { + icon: ChatTeardropTextIcon, + labelKey: 'matrix', + href: 'https://matrix.to/#/@nipsys:nips.im', + displayText: '@nipsys:nips.im', + copyable: true, + }, + { + icon: EnvelopeIcon, + labelKey: 'email', + href: email ? `mailto:${email}` : '#', + displayText: email ?? '...', + copyable: true, + }, + ]; + + const renderLink = ({ + icon: Icon, + labelKey, + href, + displayText, + copyable, + }: ContactLink) => { + return ( +
+ } + data-prevent-terminal-focus + > + + {t(`links.${labelKey}`)} + + + + + + {copyable ? ( + + ) : ( +
+ )} +
+ ); + }; + + return ( +
+
+ {t('title')} + + {t('subtitle')} + +
+ +
+ + {t('socialSection')} + + {socialLinks.map((link) => renderLink(link))} + + + {t('directSection')} + + {directLinks.map((link) => renderLink(link))} +
+
+ ); } diff --git a/src/components/cmd-outputs/GalleryOutput.tsx b/src/components/cmd-outputs/GalleryOutput.tsx new file mode 100644 index 0000000..b8a26bd --- /dev/null +++ b/src/components/cmd-outputs/GalleryOutput.tsx @@ -0,0 +1,283 @@ +'use client'; + +import { useStore } from '@nanostores/react'; +import { + Badge, + Button, + Card, + CardContent, + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, + Typography, +} from '@nipsys/lsd'; +import { + ArrowSquareOutIcon, + CaretLeftIcon, + CaretRightIcon, + ImagesIcon, +} from '@phosphor-icons/react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { useCallback, useEffect, useState } from 'react'; +import { useDayjs } from '@/hooks/useDayjs'; +import type { Translator } from '@/i18n/intl'; +import type { FeedEntry } from '@/lib/dpulse/protobuf/feed-schema'; +import { + $connectionStatus, + $error, + $feedEntries, + $isLoading, +} from '@/lib/dpulse/stores'; + +export default function GalleryOutput() { + const t = useTranslations('Gallery'); + const entries = useStore($feedEntries); + const isLoading = useStore($isLoading); + const error = useStore($error); + const connectionStatus = useStore($connectionStatus); + const isWaitingForFeed = + connectionStatus === 'connected' && entries.length === 0; + + return ( +
+
+ {t('title')} + + {t('subtitle')} + +
+ + {isLoading && entries.length === 0 ? ( + + {t('loading')} + + ) : error ? ( + + {error} + + ) : isWaitingForFeed ? ( + + {t('waitingForFeed')} + + ) : entries.length === 0 ? ( + + {t('notFound')} + + ) : ( + <> +
+ {entries.map((entry) => ( + + ))} +
+
+ + {t('feedPrefix')}{' '} + + {t('feedSuffix')} + + + + dpulse {t('dpulseSignsPrefix')}{' '} + + {t('logosDeliverySuffix')} + +
+ + )} +
+ ); +} + +function PostCard({ entry, t }: { entry: FeedEntry; t: Translator }) { + const dayjs = useDayjs(); + const [currentImageIndex, setCurrentImageIndex] = useState(0); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const hasMultipleImages = entry.images.length > 1; + + const longDate = entry.published ? dayjs(entry.published).format('LL') : ''; + const shortDate = entry.published ? dayjs(entry.published).format('L') : ''; + + const handlePreviousImage = useCallback(() => { + setCurrentImageIndex((prev) => + prev === 0 ? entry.images.length - 1 : prev - 1, + ); + }, [entry.images.length]); + + const handleNextImage = useCallback(() => { + setCurrentImageIndex((prev) => + prev === entry.images.length - 1 ? 0 : prev + 1, + ); + }, [entry.images.length]); + + useEffect(() => { + if (!isDialogOpen || !hasMultipleImages) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + handlePreviousImage(); + } else if (e.key === 'ArrowRight') { + handleNextImage(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isDialogOpen, hasMultipleImages, handlePreviousImage, handleNextImage]); + + const handleDialogOpenChange = (open: boolean) => { + setIsDialogOpen(open); + if (open) { + setCurrentImageIndex(0); + } + }; + + return ( + + + + + {entry.images.length > 0 && ( +
+ {entry.title} + {hasMultipleImages && ( + +
+ + {entry.images.length} +
+
+ )} +
+ )} +
+ + {entry.title || entry.content || 'Untitled'} + +
+ + {shortDate} + + e.stopPropagation()} + > + + +
+
+
+
+
+ +
+ + {entry.title || 'Post details'} + +
+ {entry.images.length > 0 && ( +
+ {`${entry.title} + {hasMultipleImages && ( + <> + + +
+ + {currentImageIndex + 1} / {entry.images.length} + +
+ + )} +
+ )} +
+ + {entry.title || entry.content || 'Untitled'} + + +
+
+
+ ); +} diff --git a/src/components/cmd-outputs/HelpOutput.tsx b/src/components/cmd-outputs/HelpOutput.tsx index 3f27af6..b1c1bdb 100644 --- a/src/components/cmd-outputs/HelpOutput.tsx +++ b/src/components/cmd-outputs/HelpOutput.tsx @@ -1,35 +1,34 @@ import { Typography } from '@nipsys/lsd'; -import { Component } from 'react'; +import { useTranslations } from 'next-intl'; import { Commands } from '@/constants/commands'; -import type { CommandOutputProps } from '@/types/terminal'; import CmdLink from '../terminal/CmdLink'; -export default class HelpOutput extends Component { - render() { - return ( -
- {Commands.map((cmd) => ( -
- - - {this.props.t(`cmds.${cmd.name}.description`)} - - +export default function HelpOutput() { + const t = useTranslations('Terminal'); - {(cmd.arguments ?? []).map((arg) => ( -
- - - {this.props.t(`cmds.${cmd.name}.argsDesc.${arg.name}`)} - + return ( +
+ {Commands.map((cmd) => ( +
+ + - {t(`cmds.${cmd.name}.description`)} + + - -
- ))} -
- ))} -
- ); - } + {(cmd.arguments ?? []).map((arg) => ( +
+ + - {t(`cmds.${cmd.name}.argsDesc.${arg.name}`)} + + + +
+ ))} +
+ ))} +
+ ); } diff --git a/src/components/cmd-outputs/ResumeOutput.tsx b/src/components/cmd-outputs/ResumeOutput.tsx index cf26e24..2766af2 100644 --- a/src/components/cmd-outputs/ResumeOutput.tsx +++ b/src/components/cmd-outputs/ResumeOutput.tsx @@ -1,9 +1,217 @@ -import { Component } from 'react'; -import type { CommandOutputProps } from '@/types/terminal'; -import Resume from '../resume/Resume'; +'use client'; -export default class ResumeOutput extends Component { - render() { - return ; +import { useStore } from '@nanostores/react'; +import { Badge, Button, ScrollArea, Typography } from '@nipsys/lsd'; +import { useLocale, useTranslations } from 'next-intl'; +import { useEffect, useRef, useState } from 'react'; +import { $isDarkMode } from '@/stores/theme-store'; + +const RESUME_PATHS = { + en: { + pdf: '/resume/Xavier-SALINIERE_resume.EN.pdf', + html: '/resume/Xavier-SALINIERE_resume.EN.html', + }, + fr: { + pdf: '/resume/Xavier-SALINIERE_resume.FR.pdf', + html: '/resume/Xavier-SALINIERE_resume.FR.html', + }, +}; + +async function fetchHtml(locale: 'en' | 'fr'): Promise { + const response = await fetch(RESUME_PATHS[locale].html); + if (!response.ok) { + throw new Error(`Failed to fetch resume HTML: ${response.status}`); } + return response.text(); +} + +function extractContent(html: string): { + body: string; + style: string; + script: string; +} { + const styleMatch = html.match(/]*>([\s\S]*?)<\/style>/i); + const scriptMatch = html.match( + /]*type="module"[^>]*>([\s\S]*?)<\/script>/i, + ); + const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); + + return { + style: styleMatch?.[1] || '', + script: scriptMatch?.[1] || '', + body: bodyMatch?.[1] || '', + }; +} + +function ResumeHtml({ htmlContent }: { htmlContent: string }) { + const containerRef = useRef(null); + const shadowRef = useRef(null); + const isDarkMode = useStore($isDarkMode); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + if (!containerRef.current) return; + + if (!shadowRef.current) { + shadowRef.current = containerRef.current.attachShadow({ mode: 'open' }); + } + const shadow = shadowRef.current; + + // Clear previous content before re-rendering + shadow.innerHTML = ''; + + const { style, body, script } = extractContent(htmlContent); + + const hostStyles = ` + :host { + display: block; + width: 100%; + min-height: 100%; + } + :host(.dark) { + --color-background: var(--color-background-dark, #191e23); + --color-dimmed: var(--color-dimmed-dark, #23282d); + --color-primary: var(--color-primary-dark, #fbfbfc); + --color-secondary: var(--color-secondary-dark, #ccd0d4); + --color-accent: var(--color-accent-dark, #00a0d2); + } + :host(.light) { + --color-background: var(--color-background-light, #ffffff); + --color-dimmed: var(--color-dimmed-light, #f3f4f5); + --color-primary: var(--color-primary-light, #191e23); + --color-secondary: var(--color-secondary-light, #6c7781); + --color-accent: var(--color-accent-light, #0073aa); + } + .resume-wrapper { + background: var(--color-background); + padding: 1.5rem; + min-height: 100%; + } + `; + + const styleEl = document.createElement('style'); + styleEl.textContent = hostStyles + style; + shadow.appendChild(styleEl); + + const wrapper = document.createElement('div'); + wrapper.className = 'resume-wrapper'; + wrapper.innerHTML = body; + shadow.appendChild(wrapper); + + if (script) { + // SECURITY: Only allow trusted scripts from bundled resume HTML assets. + // The script content comes from our own /public/resume/*.html files, + // not from user input. This is safe because we control the source. + const wrappedScript = ` + const originalDefine = customElements.define.bind(customElements); + customElements.define = (name, constructor, options) => { + if (!customElements.get(name)) { + originalDefine(name, constructor, options); + } + }; + ${script} + `; + const scriptEl = document.createElement('script'); + scriptEl.type = 'module'; + scriptEl.textContent = wrappedScript; + shadow.appendChild(scriptEl); + } + + setInitialized(true); + + // Cleanup: Clear shadow root content on unmount to prevent memory leak + return () => { + if (shadowRef.current) { + shadowRef.current.innerHTML = ''; + } + }; + }, [htmlContent]); + + useEffect(() => { + if (!containerRef.current || !initialized) return; + + const host = containerRef.current; + if (isDarkMode) { + host.classList.add('dark'); + host.classList.remove('light'); + } else { + host.classList.add('light'); + host.classList.remove('dark'); + } + }, [isDarkMode, initialized]); + + return
; +} + +export default function ResumeOutput() { + const locale = useLocale(); + const t = useTranslations('Resume'); + + const [html, setHtml] = useState(''); + const [error, setError] = useState(null); + + useEffect(() => { + fetchHtml(locale as 'en' | 'fr') + .then(setHtml) + .catch((err) => { + console.error('Failed to load resume:', err); + setError('Failed to load resume'); + }); + }, [locale]); + + return ( +
+
+ {t('title')} + + {t('subtitle')} + +
+ +
+ + {t('viewing')} {locale.toUpperCase()} {t('version')} + +
+ + + +
+
+ + {error ? ( +
+ {error} +
+ ) : html ? ( + + ) : ( +
+ Loading... +
+ )} +
+
+
+
+ ); } diff --git a/src/components/cmd-outputs/StatusOutput.tsx b/src/components/cmd-outputs/StatusOutput.tsx index 7ff1f0c..2a6ec6a 100644 --- a/src/components/cmd-outputs/StatusOutput.tsx +++ b/src/components/cmd-outputs/StatusOutput.tsx @@ -13,13 +13,13 @@ import { import { RecordIcon } from '@phosphor-icons/react'; import Image from 'next/image'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import { useEffect, useMemo, useState } from 'react'; import { useDayjs } from '@/hooks/useDayjs'; import { useIcon } from '@/hooks/useIcon'; import { $isLoading, $statusMessages } from '@/lib/dpulse/stores'; import type { HealthStatus, ServiceStatus } from '@/lib/dpulse/types'; import { getStatusMeta } from '@/lib/dpulse/utils/status'; -import type { CommandOutputProps } from '@/types/terminal'; interface ServiceCardProps { statusMsg: ServiceStatus; @@ -36,7 +36,7 @@ function ServiceCard({ statusMsg, t, dayjs }: ServiceCardProps) { const { blobUrl: iconBlobUrl } = useIcon(iconCid); return ( - +
@@ -72,14 +72,13 @@ function ServiceCard({ statusMsg, t, dayjs }: ServiceCardProps) { variant="body1" style={{ color: 'var(--lsd-text-secondary)' }} > - {statusMsg?.description || `${t('cmds.status.noStatusMessage')}`} + {statusMsg?.description || `${t('noStatusMessage')}`}
- {t('cmds.status.lastChecked')}{' '} - {dayjs(statusMsg.timestamp).fromNow()} + {t('lastChecked')} {dayjs(statusMsg.timestamp).fromNow()}
@@ -88,7 +87,8 @@ function ServiceCard({ statusMsg, t, dayjs }: ServiceCardProps) { ); } -export default function StatusOutput({ t }: CommandOutputProps) { +export default function StatusOutput() { + const t = useTranslations('Status'); const statusMessages = useStore($statusMessages); const isLoading = useStore($isLoading); const dayjs = useDayjs(); @@ -121,18 +121,16 @@ export default function StatusOutput({ t }: CommandOutputProps) { return (
- {t('cmds.status.title')} + {t('title')} - {t('cmds.status.subtitle')} + {t('subtitle')}
{isWaitingForHeartbeats ? (
- {isLoading - ? t('cmds.status.loading') - : t('cmds.status.waitingForHeartbeats')} + {isLoading ? t('loading') : t('waitingForHeartbeats')}
) : ( @@ -152,9 +150,12 @@ export default function StatusOutput({ t }: CommandOutputProps) { ))}
-
+
- {t('cmds.status.healthcheckPrefix')}{' '} + {t('healthcheckPrefix')}{' '} - {t('cmds.status.healthcheckSuffix')} + {t('healthcheckSuffix')} - dpulse{' '} - {t('cmds.status.dpulseSignsPrefix')}{' '} + dpulse {t('dpulseSignsPrefix')}{' '} - {t('cmds.status.logosDeliverySuffix')} + {t('logosDeliverySuffix')}
diff --git a/src/components/cmd-outputs/WelcomeOutput.tsx b/src/components/cmd-outputs/WelcomeOutput.tsx index 360ca08..8c954d9 100644 --- a/src/components/cmd-outputs/WelcomeOutput.tsx +++ b/src/components/cmd-outputs/WelcomeOutput.tsx @@ -8,12 +8,14 @@ import { TooltipTrigger, } from '@nipsys/lsd'; import Image from 'next/image'; +import { useTranslations } from 'next-intl'; import avatar from '@/assets/nipsys.webp'; import { $scrollY } from '@/stores/terminal-store'; -import { Command, type CommandOutputProps } from '@/types/terminal'; +import { Command } from '@/types/terminal'; import CmdLink from '../terminal/CmdLink'; -export default function WelcomeOutput({ t }: CommandOutputProps) { +export default function WelcomeOutput() { + const t = useTranslations('Welcome'); const scrollY = useStore($scrollY); const showTooltip = scrollY === 0; @@ -26,14 +28,14 @@ export default function WelcomeOutput({ t }: CommandOutputProps) {

- {t.rich('cmds.welcome.welcome', { + {t.rich('welcome', { name: (name) => {name}, })}

-

{t('cmds.welcome.site_intro_1')}

+

{t('siteIntro1')}

- {t.rich('cmds.welcome.site_intro_2', { + {t.rich('siteIntro2', { cmd: () => , })}

diff --git a/src/components/cmd-outputs/WhoamiOutput.tsx b/src/components/cmd-outputs/WhoamiOutput.tsx index d972c57..38d3b39 100644 --- a/src/components/cmd-outputs/WhoamiOutput.tsx +++ b/src/components/cmd-outputs/WhoamiOutput.tsx @@ -1,9 +1,117 @@ -import { Component } from 'react'; -import type { CommandOutputProps } from '@/types/terminal'; -import AboutMe from '../about-me/AboutMe'; - -export default class WhoamiOutput extends Component { - render() { - return ; - } +'use client'; + +import { + Avatar, + AvatarFallback, + AvatarImage, + Badge, + Button, + Typography, +} from '@nipsys/lsd'; +import { + BriefcaseIcon, + CopyIcon, + LockKeyIcon, + MapPinIcon, + TargetIcon, +} from '@phosphor-icons/react'; +import { useTranslations } from 'next-intl'; +import gpgFingerprint from '@/assets/gpg-fingerprint.json'; +import profileImage from '@/assets/Pro-Hacked.webp'; +import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; + +const GPG_FINGERPRINT = gpgFingerprint.fingerprint; + +export default function WhoamiOutput() { + const tAbout = useTranslations('AboutMe'); + const { copyWithToast, isCopied } = useCopyToClipboard(); + + const quickInfo = [ + { icon: MapPinIcon, textKey: 'badges.location' }, + { icon: BriefcaseIcon, textKey: 'badges.experience' }, + { icon: LockKeyIcon, textKey: 'badges.focus' }, + { icon: TargetIcon, textKey: 'badges.goal' }, + ]; + + return ( +
+
+ + + X + + +
+
+ {tAbout('name')} + + {tAbout('tagline')} + +
+ +
+ {quickInfo.map(({ icon: Icon, textKey }) => ( + } + > + {tAbout(textKey)} + + ))} +
+
+
+ +
+ {tAbout('bio.intro1')} + {tAbout('bio.intro2')} + + + {tAbout('bio.para1')} + + + + {tAbout('bio.para2')} + + + + {tAbout('bio.para3')} + + + + {tAbout('bio.para4')} + + + + {tAbout('bio.para5')} + + + {tAbout('bio.current')} +
+ +
+
+ + {tAbout('gpgLabel')} + + +
+ + {GPG_FINGERPRINT} + +
+
+ ); } diff --git a/src/components/contact/Contact.tsx b/src/components/contact/Contact.tsx deleted file mode 100644 index 4e0532b..0000000 --- a/src/components/contact/Contact.tsx +++ /dev/null @@ -1,175 +0,0 @@ -'use client'; - -import { Badge, Button, Typography } from '@nipsys/lsd'; -import { - ButterflyIcon, - ChatTeardropTextIcon, - CopyIcon, - EnvelopeIcon, - GithubLogoIcon, - type IconWeight, - LinkedinLogoIcon, - PaperPlaneTiltIcon, - TwitterLogoIcon, -} from '@phosphor-icons/react'; -import { useTranslations } from 'next-intl'; -import { useEffect, useState } from 'react'; -import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; - -// Base64 encoded email to prevent scraping (decoded after hydration) -// Generate with: btoa("your@email.com") -const ENCODED_EMAIL = 'Ym9uam91ckB4YXZpZXJzLnNo'; - -interface ContactLink { - icon: React.ComponentType<{ weight?: IconWeight; size?: number }>; - labelKey: string; - href: string; - displayText: string; - copyable?: boolean; -} - -export default function Contact() { - const t = useTranslations('Contact'); - const [email, setEmail] = useState(null); - const { copyWithToast, isCopied } = useCopyToClipboard(); - - useEffect(() => { - setEmail(atob(ENCODED_EMAIL)); - }, []); - - const socialLinks: ContactLink[] = [ - { - icon: GithubLogoIcon, - labelKey: 'github', - href: 'https://github.com/nipsysdev', - displayText: 'nipsysdev', - }, - { - icon: TwitterLogoIcon, - labelKey: 'twitter', - href: 'https://x.com/nipsysdev', - displayText: '@nipsysdev', - }, - { - icon: PaperPlaneTiltIcon, - labelKey: 'telegram', - href: 'https://t.me/nipsysdev', - displayText: '@nipsysdev', - }, - { - icon: ButterflyIcon, - labelKey: 'bluesky', - href: 'https://bsky.app/profile/nipsys.bsky.social', - displayText: '@nipsys.bsky.social', - }, - { - icon: LinkedinLogoIcon, - labelKey: 'linkedin', - href: 'https://linkedin.com/in/xaviersaliniere', - displayText: 'xaviersaliniere', - }, - ]; - - const directLinks: ContactLink[] = [ - { - icon: ChatTeardropTextIcon, - labelKey: 'matrix', - href: 'https://matrix.to/#/@nipsys:nips.im', - displayText: '@nipsys:nips.im', - copyable: true, - }, - { - icon: EnvelopeIcon, - labelKey: 'email', - href: email ? `mailto:${email}` : '#', - displayText: email ?? '...', - copyable: true, - }, - ]; - - const renderLink = ({ - icon: Icon, - labelKey, - href, - displayText, - copyable, - }: ContactLink) => { - return ( -
- } - > - {t(`links.${labelKey}`)} - - - - - {copyable ? ( - - ) : ( -
- )} -
- ); - }; - - return ( -
-
- {t('title')} - - {t('subtitle')} - -
- -
- {/* Social Section */} - - {t('socialSection')} - - {socialLinks.map((link) => renderLink(link))} - - {/* Direct Contact Section */} - - {t('directSection')} - - {directLinks.map((link) => renderLink(link))} -
-
- ); -} diff --git a/src/components/layout/Sidenav.tsx b/src/components/layout/Sidenav.tsx index a3ed221..cbe5aab 100644 --- a/src/components/layout/Sidenav.tsx +++ b/src/components/layout/Sidenav.tsx @@ -29,20 +29,19 @@ import { Routes } from '@/constants/routes'; import { Link, usePathname } from '@/i18n/intl'; import { $connectionStatus, $error, $peerCount } from '@/lib/dpulse/stores'; import { getConnectionMeta } from '@/lib/dpulse/utils/status'; -import { $scrollY, $terminalPromptRef } from '@/stores/terminal-store'; +import { $scrollY } from '@/stores/terminal-store'; import Header from './Header'; export default function Sidenav({ children }: { children: React.ReactNode }) { const t = useTranslations('Pages'); const tSidebar = useTranslations('Sidebar'); - const tStatus = useTranslations('Status'); + const tDelivery = useTranslations('logosDelivery'); const pathname = usePathname(); const connectionStatus = useStore($connectionStatus); const error = useStore($error); const peerCount = useStore($peerCount); const activePath = pathname === '/' ? pathname : pathname.replace(/\/+$/, ''); - const terminalPromptRef = useStore($terminalPromptRef); const handleScroll = (e: React.UIEvent) => { $scrollY.set(e.currentTarget.scrollTop); @@ -84,7 +83,7 @@ export default function Sidenav({ children }: { children: React.ReactNode }) { className={`size-4 ${statusMeta.className}`} /> - {tStatus(statusMeta.textKey.split('.')[1])} + {tDelivery(statusMeta.textKey.split('.')[1])} {peerCount > 0 && ( <> @@ -150,10 +149,7 @@ export default function Sidenav({ children }: { children: React.ReactNode }) {
- terminalPromptRef?.current?.focus()} - > +
{children}
diff --git a/src/components/resume/Resume.tsx b/src/components/resume/Resume.tsx deleted file mode 100644 index b0bd27d..0000000 --- a/src/components/resume/Resume.tsx +++ /dev/null @@ -1,103 +0,0 @@ -'use client'; - -import { Badge, Button, ScrollArea, Typography } from '@nipsys/lsd'; -import { useLocale, useTranslations } from 'next-intl'; -import { useEffect, useState } from 'react'; -import { ResumeHtml } from './ResumeHtml'; - -const RESUME_PATHS = { - en: { - pdf: '/resume/Xavier-SALINIERE_resume.EN.pdf', - html: '/resume/Xavier-SALINIERE_resume.EN.html', - }, - fr: { - pdf: '/resume/Xavier-SALINIERE_resume.FR.pdf', - html: '/resume/Xavier-SALINIERE_resume.FR.html', - }, -}; - -interface ResumeProps { - htmlEn?: string; - htmlFr?: string; -} - -async function fetchHtml(locale: 'en' | 'fr'): Promise { - const response = await fetch(RESUME_PATHS[locale].html); - if (!response.ok) { - throw new Error(`Failed to fetch resume HTML: ${response.status}`); - } - return response.text(); -} - -export default function Resume({ htmlEn, htmlFr }: ResumeProps) { - const locale = useLocale(); - const t = useTranslations('Terminal.cmds.resume'); - - const currentLocale = locale === 'fr' ? 'fr' : 'en'; - - const [html, setHtml] = useState(''); - const [error, setError] = useState(null); - - useEffect(() => { - const initialHtml = currentLocale === 'fr' ? htmlFr : htmlEn; - if (initialHtml) { - setHtml(initialHtml); - return; - } - - fetchHtml(currentLocale) - .then(setHtml) - .catch((err) => { - console.error('Failed to load resume:', err); - setError('Failed to load resume'); - }); - }, [currentLocale, htmlEn, htmlFr]); - - return ( -
-
- {t('title')} - - {t('subtitle')} - -
- -
- - {t('viewing')} {currentLocale.toUpperCase()} {t('version')} - -
- - - -
-
- - {error ? ( -
- {error} -
- ) : html ? ( - - ) : ( -
- Loading... -
- )} -
-
-
-
- ); -} diff --git a/src/components/resume/ResumeHtml.tsx b/src/components/resume/ResumeHtml.tsx deleted file mode 100644 index f35216f..0000000 --- a/src/components/resume/ResumeHtml.tsx +++ /dev/null @@ -1,107 +0,0 @@ -'use client'; - -import { useStore } from '@nanostores/react'; -import { useEffect, useRef, useState } from 'react'; -import { $isDarkMode } from '@/stores/theme-store'; - -interface ResumeHtmlProps { - htmlContent: string; -} - -function extractContent(html: string): { - body: string; - style: string; - script: string; -} { - const styleMatch = html.match(/]*>([\s\S]*?)<\/style>/i); - const scriptMatch = html.match( - /]*type="module"[^>]*>([\s\S]*?)<\/script>/i, - ); - const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); - - return { - style: styleMatch?.[1] || '', - script: scriptMatch?.[1] || '', - body: bodyMatch?.[1] || '', - }; -} - -export function ResumeHtml({ htmlContent }: ResumeHtmlProps) { - const containerRef = useRef(null); - const shadowRef = useRef(null); - const isDarkMode = useStore($isDarkMode); - const [initialized, setInitialized] = useState(false); - - useEffect(() => { - if (!containerRef.current) return; - - if (!shadowRef.current) { - shadowRef.current = containerRef.current.attachShadow({ mode: 'open' }); - } - - const shadow = shadowRef.current; - const { style, body, script } = extractContent(htmlContent); - - shadow.innerHTML = ''; - - const hostStyles = ` - :host { - display: block; - width: 100%; - min-height: 100%; - } - :host(.dark) { - --color-background: var(--color-background-dark, #191e23); - --color-dimmed: var(--color-dimmed-dark, #23282d); - --color-primary: var(--color-primary-dark, #fbfbfc); - --color-secondary: var(--color-secondary-dark, #ccd0d4); - --color-accent: var(--color-accent-dark, #00a0d2); - } - :host(.light) { - --color-background: var(--color-background-light, #ffffff); - --color-dimmed: var(--color-dimmed-light, #f3f4f5); - --color-primary: var(--color-primary-light, #191e23); - --color-secondary: var(--color-secondary-light, #6c7781); - --color-accent: var(--color-accent-light, #0073aa); - } - .resume-wrapper { - background: var(--color-background); - padding: 1.5rem; - min-height: 100%; - } - `; - - const styleEl = document.createElement('style'); - styleEl.textContent = hostStyles + style; - shadow.appendChild(styleEl); - - const wrapper = document.createElement('div'); - wrapper.className = 'resume-wrapper'; - wrapper.innerHTML = body; - shadow.appendChild(wrapper); - - if (script) { - const scriptEl = document.createElement('script'); - scriptEl.type = 'module'; - scriptEl.textContent = script; - shadow.appendChild(scriptEl); - } - - setInitialized(true); - }, [htmlContent]); - - useEffect(() => { - if (!containerRef.current || !initialized) return; - - const host = containerRef.current; - if (isDarkMode) { - host.classList.add('dark'); - host.classList.remove('light'); - } else { - host.classList.add('light'); - host.classList.remove('dark'); - } - }, [isDarkMode, initialized]); - - return
; -} diff --git a/src/components/terminal/TerminalEmulator.tsx b/src/components/terminal/TerminalEmulator.tsx index 4e881f6..1c15cd3 100644 --- a/src/components/terminal/TerminalEmulator.tsx +++ b/src/components/terminal/TerminalEmulator.tsx @@ -48,6 +48,16 @@ export default function TerminalEmulator({ } }, [hasWindow, initialCommand]); + const focusTerminal = (target: HTMLElement) => { + if ( + !target.closest( + '[data-prevent-terminal-focus],[data-slot="dialog-overlay"],[data-radix-popper-content-wrapper]', + ) + ) { + mainPrompt.current?.focus(); + } + }; + return ( hasWindow && (
@@ -56,14 +66,19 @@ export default function TerminalEmulator({ role="button" tabIndex={0} className="flex size-full cursor-default flex-col" - onKeyDown={() => {}} - onClick={() => mainPrompt.current?.focus()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + mainPrompt.current?.focus(); + } + }} + onClick={(e) => focusTerminal(e.target as HTMLElement)} > {history.slice(historyVisibleIdx).map((entry) => (
{entry.output ? ( - + ) : ( entry.cmdName && )} diff --git a/src/constants/commands.ts b/src/constants/commands.ts index 944dec3..ed9f675 100644 --- a/src/constants/commands.ts +++ b/src/constants/commands.ts @@ -1,5 +1,6 @@ import BuildInfoOutput from '@/components/cmd-outputs/BuildInfoOutput'; import ContactOutput from '@/components/cmd-outputs/ContactOutput'; +import GalleryOutput from '@/components/cmd-outputs/GalleryOutput'; import HelpOutput from '@/components/cmd-outputs/HelpOutput'; import ResumeOutput from '@/components/cmd-outputs/ResumeOutput'; import StatusOutput from '@/components/cmd-outputs/StatusOutput'; @@ -32,6 +33,10 @@ export const Commands: CommandInfo[] = [ name: Command.Contact, output: ContactOutput, }, + { + name: Command.Gallery, + output: GalleryOutput, + }, { name: Command.Clear, }, diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 66d88d7..6cd4c52 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -1,6 +1,7 @@ export const Routes = { welcome: '/', whoami: '/whoami', + gallery: '/gallery', resume: '/resume', status: '/status', contact: '/contact', diff --git a/src/hooks/useCopyToClipboard.ts b/src/hooks/useCopyToClipboard.ts index 74863fc..1154c06 100644 --- a/src/hooks/useCopyToClipboard.ts +++ b/src/hooks/useCopyToClipboard.ts @@ -1,25 +1,44 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; export function useCopyToClipboard() { const [copiedKey, setCopiedKey] = useState(null); + const timeoutRef = useRef | null>(null); + const mountedRef = useRef(true); - const copy = async (text: string, key: string = 'default') => { + useEffect(() => { + return () => { + mountedRef.current = false; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const copy = useCallback(async (text: string, key: string = 'default') => { await navigator.clipboard.writeText(text); - setCopiedKey(key); - setTimeout(() => setCopiedKey(null), 200); - }; + if (mountedRef.current) { + setCopiedKey(key); + timeoutRef.current = setTimeout(() => { + if (mountedRef.current) { + setCopiedKey(null); + } + }, 200); + } + }, []); - const copyWithToast = async ( - text: string, - toastMessage: string, - key: string = 'default', - ) => { - await copy(text, key); - toast.success(toastMessage); - }; + const copyWithToast = useCallback( + async (text: string, toastMessage: string, key: string = 'default') => { + await copy(text, key); + toast.success(toastMessage); + }, + [copy], + ); - const isCopied = (key: string = 'default') => copiedKey === key; + const isCopied = useCallback( + (key: string = 'default') => copiedKey === key, + [copiedKey], + ); return { copy, copyWithToast, isCopied }; } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index fa4d1ea..89d8edc 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -15,11 +15,12 @@ "Pages": { "welcome": "welcome", "whoami": "whoami", + "gallery": "gallery", "status": "status", "contact": "contact", "resume": "resume" }, - "Status": { + "logosDelivery": { "connected": "Connected", "connecting": "Connecting...", "disconnected": "Disconnected" @@ -69,7 +70,8 @@ "bluesky": "BlueSky", "telegram": "Telegram", "matrix": "Matrix", - "email": "Email" + "email": "Email", + "pixelfed": "Pixelfed" }, "copyToast": "Copied to clipboard", "copyLabel": "Copy to clipboard" @@ -80,12 +82,7 @@ "visitor": "visitor", "cmds": { "build-info": { - "description": "Show build timestamp and deployment info", - "title": "Build Information", - "timeLabel": "Build Time:", - "ipnsLabel": "IPNS Name:", - "unknown": "Unknown", - "notConfigured": "Not configured" + "description": "Show build timestamp and deployment info" }, "clear": { "description": "Clear the terminal screen" @@ -97,43 +94,71 @@ "description": "Print list of commands" }, "welcome": { - "description": "Print welcome message", - "welcome": "Hi there, I'm Xav! Welcome to my own little corner of cyberspace :)", - "site_intro_1": "This site can be browsed by clicking on the links in the side navigation.", - "site_intro_2": "Or, for a CLI experience... Get started with " + "description": "Print welcome message" }, "whoami": { "description": "Allow me to introduce myself" }, + "gallery": { + "description": "View the last pictures I posted on Pixelfed" + }, "status": { - "description": "Display my self-hosted services status", - "title": "Status", - "subtitle": "Status of the services that I proudly self-host", - "connectionStatus": "Connection Status", - "loading": "(Loading...)", - "waitingForHeartbeats": "Waiting for heartbeats...", - "errorLabel": "Error", - "lastChecked": "Seen", - "noStatusMessage": "No status message available", - "healthcheckPrefix": "Healthchecks are performed by", - "healthcheckSuffix": ". Which runs every 5min as a systemd service on a VPS.", - "dpulseSignsPrefix": "signs, and publishes the results which are then retrieved by this static site via", - "logosDeliverySuffix": ", a peer-to-peer & censorship-resistant messaging network.", - "status": { - "healthy": "Stable", - "degraded": "Degraded", - "down": "Down" - } + "description": "Display my self-hosted services status" }, "resume": { - "description": "View my resume/CV", - "title": "Resume", - "subtitle": "View and download my resume in PDF format", - "downloadEN": "Download English PDF", - "downloadFR": "Download French PDF", - "viewing": "Currently viewing", - "version": "version" + "description": "View my resume/CV" } } + }, + "Welcome": { + "welcome": "Hi there, I'm Xav! Welcome to my own little corner of cyberspace :)", + "siteIntro1": "This site can be browsed by clicking on the links in the side navigation.", + "siteIntro2": "Or, for a CLI experience... Get started with " + }, + "BuildInfo": { + "title": "Build Information", + "timeLabel": "Build Time:", + "ipnsLabel": "IPNS Name:", + "unknown": "Unknown", + "notConfigured": "Not configured" + }, + "Status": { + "title": "Status", + "subtitle": "Status of the services that I proudly self-host", + "connectionStatus": "Connection Status", + "loading": "Connecting to Logos Delivery...", + "waitingForHeartbeats": "Waiting for heartbeats...", + "errorLabel": "Error", + "lastChecked": "Seen", + "noStatusMessage": "No status message available", + "healthcheckPrefix": "Healthchecks are performed by", + "healthcheckSuffix": ". Which runs as a systemd service on a VPS.", + "dpulseSignsPrefix": "signs, and publishes the results which are then retrieved by this static site via", + "logosDeliverySuffix": ", a peer-to-peer & censorship-resistant messaging network.", + "status": { + "healthy": "Stable", + "degraded": "Degraded", + "down": "Down" + } + }, + "Resume": { + "title": "Resume", + "subtitle": "View and download my resume in PDF format", + "downloadEN": "Download English PDF", + "downloadFR": "Download French PDF", + "viewing": "Currently viewing", + "version": "version" + }, + "Gallery": { + "title": "Gallery", + "subtitle": "Here are the most recent pictures I posted on Pixelfed", + "loading": "Connecting to Logos Delivery...", + "notFound": "No posts found.", + "waitingForFeed": "Waiting for feed entries...", + "viewOnPixelfed": "View on Pixelfed", + "feedPrefix": "Pixelfed feed is provided by", + "feedSuffix": ". Which runs as a systemd service on a VPS.", + "dpulseSignsPrefix": "retrieves, signs, and publishes the feed which is then retrieved by this static site via", + "logosDeliverySuffix": ", a peer-to-peer & censorship-resistant messaging network." } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 5667d34..f325461 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -15,11 +15,12 @@ "Pages": { "welcome": "bienvenue", "whoami": "qui suis-je", + "gallery": "galerie", "status": "statut", "contact": "contact", "resume": "cv" }, - "Status": { + "logosDelivery": { "connected": "Connecté", "connecting": "Connexion...", "disconnected": "Déconnecté" @@ -37,10 +38,10 @@ }, "AboutMe": { "name": "Xav [nipsys]", - "tagline": "cypherpunk / développeur / défenseur de la souveraineté numérique", + "tagline": "cypherpunk / développeur / militant pour la souveraineté numérique", "badges": { "location": "France → Canada → Suisse", - "experience": "10+ ans à construire pour le web", + "experience": "10+ ans à construire sur la toile", "focus": "Confidentialité, Décentralisation, FLOSS", "goal": "Pentester en devenir" }, @@ -49,10 +50,10 @@ "intro2": "Une simple question me motive : et si l'internet pouvait réellement être au service de ceux qui l'utilisent ?", "para1": "J'ai commencé à coder à 14 ans, en bricolant des homebrews PSP dans ma chambre. Ce qui a commencé comme une curiosité d'adolescent s'est transformé en un voyage d'une décennie à travers le monde du développement web — depuis l'abandon de mes études à 21 ans pour rejoindre une startup, jusqu'à développer des plateformes de sécurité d'entreprise au Centre des Opérations de Bell Canada.", "para2": "Mais la vraie histoire n'est pas dans les postes que j'ai occupé. Elle est dans les nuits blanches à expérimenter et à découvrir des systèmes et technologies alternatifs.", - "para3": "Plonger dans Linux, la philosophie FLOSS, l'auto-hébergement de divers services, et progressivement m'extraire des \"walled gardens\" de la Big Tech. Plus j'apprenais sur les systèmes centralisés et les organisations qui les contrôlent, plus je suis devenu passioné par leurs alternatives.", + "para3": "Je me suis plongé dans Linux, la philosophie FLOSS, l'auto-hébergement de divers services, et progressivement je me suis extrait des \"walled gardens\" de la Big Tech. Plus j'apprenais sur les systèmes centralisés et les organisations qui les contrôlent, plus je suis devenu passioné par leurs alternatives.", "para4": "Après 4 ans au Canada, je m'installe maintenant en Suisse — emportant avec moi ma passion pour les technologies décentralisées, \"permissionless\" et respectueuses de la vie privée.", "para5": "Côté carrière, après 10 ans en développement web, je me forme pour transitionner vers la cybersécurité, avec un fort intérêt pour le pentesting.", - "current": "Ce que je fais actuellement : Me former en cybersécurité, développer AnyMaps et de son infrastructure décentralisée, contribuer aux écosystèmes qui alimentent mes projets, et mes expérimentations routinières :)" + "current": "Ce que je fais actuellement : Me former en cybersécurité, développer AnyMaps et son infrastructure décentralisée, contribuer aux écosystèmes qui alimentent mes projets, et mes expérimentations routinières :)" }, "gpgLabel": "Empreinte GPG", "copyToast": "Empreinte GPG copiée dans le presse-papier" @@ -80,12 +81,7 @@ "visitor": "visiteur", "cmds": { "build-info": { - "description": "Afficher la date du build et les informations de déploiement", - "title": "Informations du build", - "timeLabel": "Date du build :", - "ipnsLabel": "Nom IPNS :", - "unknown": "Inconnu", - "notConfigured": "Non configuré" + "description": "Afficher la date du build et les informations de déploiement" }, "clear": { "description": "Effacer l'écran du terminal" @@ -97,43 +93,71 @@ "description": "Afficher la liste des commandes" }, "welcome": { - "description": "Afficher le message d'introduction", - "welcome": "Hey salut, je suis Xav! Bienvenue dans mon pti chez moi en plein cyberspace :)", - "site_intro_1": "Ce site peut être parcouru en cliquant sur les liens présents dans la navigation latérale.", - "site_intro_2": "Ou, pour une expérience CLI... Commencez en cliquant sur " + "description": "Afficher le message de bienvenue" }, "whoami": { "description": "Permettez-moi de me présenter" }, + "gallery": { + "description": "Voir les dernières photos que j'ai publiées sur Pixelfed" + }, "status": { - "description": "Afficher l'état de mes services auto-hébergés", - "title": "Statut", - "subtitle": "Statut des services que je suis fier d'héberger", - "connectionStatus": "État de la connexion", - "loading": "(Chargement...)", - "waitingForHeartbeats": "En attente de heartbeats...", - "errorLabel": "Erreur", - "lastChecked": "Vu", - "noStatusMessage": "Aucun message de statut disponible", - "healthcheckPrefix": "Les vérifications de l'état des services sont effectuées par", - "healthcheckSuffix": ". Qui est lancé toutes les 5min en tant que service systemd sur un VPS.", - "dpulseSignsPrefix": "signe et publie les résultats qui sont ensuite récupérés par ce site statique via", - "logosDeliverySuffix": ", un réseau de messagerie pair-à-pair et résistant à la censure.", - "status": { - "healthy": "Stable", - "degraded": "Dégradé", - "down": "H.S." - } + "description": "Afficher l'état de mes services auto-hébergés" }, "resume": { - "description": "Voir mon CV", - "title": "CV", - "subtitle": "Afficher et télécharger mon CV en format PDF", - "downloadEN": "Télécharger le PDF en Anglais", - "downloadFR": "Télécharger le PDF Français", - "viewing": "Version", - "version": "actuellement affichée" + "description": "Voir mon CV" } } + }, + "Welcome": { + "welcome": "Salut, je suis Xav! Bienvenue dans mon petit chez moi en plein cyberspace :)", + "siteIntro1": "Ce site peut être parcouru en cliquant sur les liens dans la navigation latérale.", + "siteIntro2": "Ou, pour une expérience CLI... Commencez avec " + }, + "BuildInfo": { + "title": "Informations du build", + "timeLabel": "Date du build :", + "ipnsLabel": "Nom IPNS :", + "unknown": "Inconnu", + "notConfigured": "Non configuré" + }, + "Status": { + "title": "Statut", + "subtitle": "État des services que je suis fier d'auto-héberger", + "connectionStatus": "État de la connexion", + "loading": "Connexion à Logos Delivery...", + "waitingForHeartbeats": "En attente de heartbeats...", + "errorLabel": "Erreur", + "lastChecked": "Vu", + "noStatusMessage": "Aucun message de statut disponible", + "healthcheckPrefix": "Les vérifications de l'état des services sont effectuées par", + "healthcheckSuffix": ". Qui tourne en tant que service systemd sur un VPS.", + "dpulseSignsPrefix": "signe et publie les résultats qui sont ensuite récupérés par ce site statique via", + "logosDeliverySuffix": ", un réseau de messagerie pair-à-pair et résistant à la censure.", + "status": { + "healthy": "Stable", + "degraded": "Dégradé", + "down": "H.S." + } + }, + "Resume": { + "title": "CV", + "subtitle": "Afficher et télécharger mon CV en format PDF", + "downloadEN": "Télécharger le PDF en anglais", + "downloadFR": "Télécharger le PDF en français", + "viewing": "Version", + "version": "actuellement affichée" + }, + "Gallery": { + "title": "Galerie", + "subtitle": "Voici les dernières photos que j'ai publiées sur Pixelfed", + "loading": "Connexion à Logos Delivery...", + "notFound": "Aucune publication trouvé.", + "waitingForFeed": "En attente du flux de publication...", + "viewOnPixelfed": "Voir sur Pixelfed", + "feedPrefix": "Le flux Pixelfed est fourni par", + "feedSuffix": ". Qui tourne en tant que service systemd sur un VPS.", + "dpulseSignsPrefix": "récupère, signe et publie le flux qui est ensuite récupéré par ce site statique via", + "logosDeliverySuffix": ", un réseau de messagerie pair-à-pair et résistant à la censure." } } diff --git a/src/lib/dayjs.ts b/src/lib/dayjs.ts index 01ccf0d..71821ab 100644 --- a/src/lib/dayjs.ts +++ b/src/lib/dayjs.ts @@ -1,16 +1,14 @@ import dayjs from 'dayjs'; import 'dayjs/locale/en'; import 'dayjs/locale/fr'; +import localizedFormat from 'dayjs/plugin/localizedFormat'; import relativeTime from 'dayjs/plugin/relativeTime'; import updateLocale from 'dayjs/plugin/updateLocale'; dayjs.extend(relativeTime); dayjs.extend(updateLocale); +dayjs.extend(localizedFormat); dayjs.locale('en'); -export function setDayjsLocale(locale: string): void { - dayjs.locale(locale); -} - export default dayjs; diff --git a/src/lib/dpulse/config.ts b/src/lib/dpulse/config.ts index d23be12..20f4af9 100644 --- a/src/lib/dpulse/config.ts +++ b/src/lib/dpulse/config.ts @@ -1,5 +1,6 @@ export const DPULSE_CONFIG = { - contentTopic: '/dpulse_site/1.0.0/prod/proto', + contentTopic: '/nipsys_site_status/1.0.0/prod/proto', + feedContentTopic: '/nipsys_site_feed/1.0.0/prod/proto', publicKey: `-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAt2CsQmm+c5L0UQmpkowqQ1WqTcFHsv6kZhI508t1sXU= -----END PUBLIC KEY-----`, diff --git a/src/lib/dpulse/feed-processor.ts b/src/lib/dpulse/feed-processor.ts new file mode 100644 index 0000000..7807d0c --- /dev/null +++ b/src/lib/dpulse/feed-processor.ts @@ -0,0 +1,36 @@ +import { DPULSE_CONFIG } from './config'; +import { validateAndDecodeFeedBatch } from './protobuf/feed-codec'; +import { $feedEntries, $feedLastFetched } from './stores'; + +export async function processFeedBatch( + payload: Uint8Array, + source: 'filter' | 'store', +): Promise { + const batch = await validateAndDecodeFeedBatch( + payload, + DPULSE_CONFIG.publicKey, + ); + + if (!batch) { + console.warn(`[dpulse] ${source}: Invalid feed batch, skipping`); + return false; + } + + // Only update if this batch is newer than the last one we processed + const lastFetched = $feedLastFetched.get(); + if (lastFetched !== null && batch.fetchedAt <= lastFetched) { + console.log( + `[dpulse] ${source}: Skipping older batch (fetchedAt: ${batch.fetchedAt}, lastFetched: ${lastFetched})`, + ); + return false; + } + + $feedEntries.set(batch.entries); + $feedLastFetched.set(batch.fetchedAt); + + console.log( + `[dpulse] ${source}: Updated feed with ${batch.entries.length} entries (fetchedAt: ${batch.fetchedAt})`, + ); + + return true; +} diff --git a/src/lib/dpulse/protobuf/codec.ts b/src/lib/dpulse/protobuf/codec.ts index 3933dc5..8658981 100644 --- a/src/lib/dpulse/protobuf/codec.ts +++ b/src/lib/dpulse/protobuf/codec.ts @@ -112,7 +112,7 @@ export async function validateAndDecodeStatusMessage( const isValid = await verifyMessage(payloadBytes, signature, publicKey); if (!isValid) { - console.error( + console.warn( 'StatusMessage verification failed: invalid signature for', message.serviceName, ); diff --git a/src/lib/dpulse/protobuf/feed-codec.ts b/src/lib/dpulse/protobuf/feed-codec.ts new file mode 100644 index 0000000..e59e676 --- /dev/null +++ b/src/lib/dpulse/protobuf/feed-codec.ts @@ -0,0 +1,102 @@ +import { + base64ToSignature, + importPublicKey, + verifyMessage, +} from '../crypto/signature'; +import type { FeedBatch as FeedBatchType, FeedEntry } from './feed-schema'; +import { FeedBatch } from './feed-schema'; + +export function decodeFeedBatch(bytes: Uint8Array): FeedBatchType { + const decoded = FeedBatch.decode(bytes); + return FeedBatch.toObject(decoded, { + longs: Number, + enums: Number, + bytes: Uint8Array, + }) as unknown as FeedBatchType; +} + +export function validateFeedBatch(batch: FeedBatchType): boolean { + if (!batch.source || typeof batch.source !== 'string') { + console.error('FeedBatch validation failed: missing or invalid source'); + return false; + } + + if (!batch.fetchedAt || typeof batch.fetchedAt !== 'number') { + console.error('FeedBatch validation failed: missing or invalid fetchedAt'); + return false; + } + + if (!Array.isArray(batch.entries)) { + console.error('FeedBatch validation failed: missing or invalid entries'); + return false; + } + + for (const entry of batch.entries) { + if (!entry.id || typeof entry.id !== 'string') { + console.error('FeedBatch validation failed: entry missing id'); + return false; + } + if (!entry.title || typeof entry.title !== 'string') { + console.error('FeedBatch validation failed: entry missing title'); + return false; + } + if (!entry.link || typeof entry.link !== 'string') { + console.error('FeedBatch validation failed: entry missing link'); + return false; + } + if (!entry.images || !Array.isArray(entry.images)) { + console.error('FeedBatch validation failed: entry missing images'); + return false; + } + } + + if (!batch.signature || typeof batch.signature !== 'string') { + console.error('FeedBatch validation failed: missing signature'); + return false; + } + + return true; +} + +export function serializeEntryForSigning(entry: FeedEntry): string { + const imagesStr = entry.images + .map((img) => `${img.url}:${img.mimeType}`) + .join(','); + return `${entry.id}:${entry.title}:${entry.link}:${entry.content || ''}:${entry.author || ''}:${entry.published?.toString() || ''}:${imagesStr}`; +} + +export async function validateAndDecodeFeedBatch( + bytes: Uint8Array, + publicKeyPem: string, +): Promise { + const batch = decodeFeedBatch(bytes); + + if (!validateFeedBatch(batch)) { + return null; + } + + try { + const publicKey = await importPublicKey(publicKeyPem); + + const entriesStr = batch.entries.map(serializeEntryForSigning).join('|'); + const payload = `${batch.source}:${entriesStr}:${batch.fetchedAt.toString()}`; + const payloadBytes = new TextEncoder().encode(payload); + + const signature = base64ToSignature(batch.signature as string); + + const isValid = await verifyMessage(payloadBytes, signature, publicKey); + + if (!isValid) { + console.warn( + 'FeedBatch verification failed: invalid signature for', + batch.source, + ); + return null; + } + + return batch; + } catch (error) { + console.error('FeedBatch verification error:', error); + return null; + } +} diff --git a/src/lib/dpulse/protobuf/feed-schema.ts b/src/lib/dpulse/protobuf/feed-schema.ts new file mode 100644 index 0000000..2719c25 --- /dev/null +++ b/src/lib/dpulse/protobuf/feed-schema.ts @@ -0,0 +1,51 @@ +import type { Type } from 'protobufjs'; +import protobuf from 'protobufjs'; + +export interface FeedImage { + url: string; + mimeType: string; +} + +export interface FeedEntry { + id: string; + title: string; + link: string; + content?: string; + author?: string; + published?: number; + images: FeedImage[]; +} + +export interface FeedBatch { + source: string; + fetchedAt: number; + entries: FeedEntry[]; + signature?: string; +} + +const FeedImageType = new protobuf.Type('FeedImage') + .add(new protobuf.Field('url', 1, 'string')) + .add(new protobuf.Field('mimeType', 2, 'string')); + +const FeedEntryType = new protobuf.Type('FeedEntry') + .add(new protobuf.Field('id', 1, 'string')) + .add(new protobuf.Field('title', 2, 'string')) + .add(new protobuf.Field('link', 3, 'string')) + .add(new protobuf.Field('content', 4, 'string', 'optional')) + .add(new protobuf.Field('author', 5, 'string', 'optional')) + .add(new protobuf.Field('published', 6, 'int64', 'optional')) + .add(new protobuf.Field('images', 7, 'FeedImage', 'repeated')); + +const FeedBatchType = new protobuf.Type('FeedBatch') + .add(new protobuf.Field('source', 1, 'string')) + .add(new protobuf.Field('fetchedAt', 2, 'int64')) + .add(new protobuf.Field('entries', 3, 'FeedEntry', 'repeated')) + .add(new protobuf.Field('signature', 4, 'string', 'optional')); + +const root = new protobuf.Root() + .define('dpulse') + .add(FeedImageType) + .add(FeedEntryType) + .add(FeedBatchType); + +export const FeedBatch = root.lookupType('dpulse.FeedBatch') as unknown as Type; diff --git a/src/lib/dpulse/stores.ts b/src/lib/dpulse/stores.ts index 8748df4..0daa660 100644 --- a/src/lib/dpulse/stores.ts +++ b/src/lib/dpulse/stores.ts @@ -1,4 +1,6 @@ -import { atom } from 'nanostores'; +import { atom, onMount } from 'nanostores'; +import { initWaku } from './manager'; +import type { FeedEntry } from './protobuf/feed-schema'; import type { ConnectionStatus, ServiceStatus } from './types'; export const $connectionStatus = atom('disconnected'); @@ -6,3 +8,11 @@ export const $statusMessages = atom>(new Map()); export const $isLoading = atom(false); export const $error = atom(null); export const $peerCount = atom(0); + +// Feed stores +export const $feedEntries = atom([]); +export const $feedLastFetched = atom(null); + +onMount($connectionStatus, () => { + initWaku(); +}); diff --git a/src/lib/dpulse/utils/status.ts b/src/lib/dpulse/utils/status.ts index c6f1680..a28b908 100644 --- a/src/lib/dpulse/utils/status.ts +++ b/src/lib/dpulse/utils/status.ts @@ -12,19 +12,19 @@ export const STATUS_META = { healthy: { variant: 'success' as const, icon: CheckCircleIcon, - textKey: 'cmds.status.status.healthy', + textKey: 'status.healthy', className: '', }, degraded: { variant: 'warning' as const, icon: CircleNotchIcon, - textKey: 'cmds.status.status.degraded', + textKey: 'status.degraded', className: 'animate-spin', }, down: { variant: 'destructive' as const, icon: XCircleIcon, - textKey: 'cmds.status.status.down', + textKey: 'status.down', className: '', }, }; diff --git a/src/lib/dpulse/waku-filter-client.ts b/src/lib/dpulse/waku-filter-client.ts index d11e0ab..9531440 100644 --- a/src/lib/dpulse/waku-filter-client.ts +++ b/src/lib/dpulse/waku-filter-client.ts @@ -1,23 +1,38 @@ import type { IDecodedMessage, IDecoder, LightNode } from '@waku/sdk'; import { DPULSE_CONFIG } from './constants'; +import { processFeedBatch } from './feed-processor'; import { setError } from './manager'; import { processMessagePayload } from './message-processor'; let decoder: IDecoder | null = null; +let feedDecoder: IDecoder | null = null; export function getDecoder(): IDecoder | null { return decoder; } +export function getFeedDecoder(): IDecoder | null { + return feedDecoder; +} + export async function setupFilterSubscription(node: LightNode): Promise { decoder = node.createDecoder({ contentTopic: DPULSE_CONFIG.contentTopic }); + feedDecoder = node.createDecoder({ + contentTopic: DPULSE_CONFIG.feedContentTopic, + }); console.log( `[dpulse] Subscribing to content topic:`, DPULSE_CONFIG.contentTopic, ); + console.log( + `[dpulse] Subscribing to feed content topic:`, + DPULSE_CONFIG.feedContentTopic, + ); + await node.filter.subscribe([decoder], handleMessage); + await node.filter.subscribe([feedDecoder], handleFeedMessage); console.log('[dpulse] Successfully subscribed to dpulse messages'); } @@ -37,3 +52,21 @@ async function handleMessage(wakuMessage: IDecodedMessage): Promise { ); } } + +async function handleFeedMessage(wakuMessage: IDecodedMessage): Promise { + try { + const payload = wakuMessage.payload; + if (!payload || payload.length === 0) { + return; + } + + await processFeedBatch(payload, 'filter'); + } catch (error) { + console.error('[dpulse] Error handling feed message:', error); + setError( + error instanceof Error + ? error.message + : 'Unknown error handling feed message', + ); + } +} diff --git a/src/lib/dpulse/waku-node.ts b/src/lib/dpulse/waku-node.ts index 13a7f77..803cf7b 100644 --- a/src/lib/dpulse/waku-node.ts +++ b/src/lib/dpulse/waku-node.ts @@ -13,8 +13,12 @@ import { } from './constants'; import { scheduleRetry } from './retry-manager'; import { $connectionStatus, $peerCount } from './stores'; -import { getDecoder, setupFilterSubscription } from './waku-filter-client'; -import { queryStoreHistory } from './waku-store-client'; +import { + getDecoder, + getFeedDecoder, + setupFilterSubscription, +} from './waku-filter-client'; +import { queryFeedHistory, queryStoreHistory } from './waku-store-client'; let wakuNode: LightNode | null = null; let healthListener: ((event: CustomEvent) => void) | null = null; @@ -81,12 +85,24 @@ export async function cleanup(): Promise { } const decoder = getDecoder(); - if (wakuNode && decoder) { - try { - await wakuNode.filter.unsubscribe([decoder]); + const feedDecoder = getFeedDecoder(); + if (wakuNode) { + if (decoder) { + try { + await wakuNode.filter.unsubscribe([decoder]); + } catch (error) { + console.warn('[dpulse] Failed to unsubscribe from Filter:', error); + } + } + if (feedDecoder) { + try { + await wakuNode.filter.unsubscribe([feedDecoder]); + } catch (error) { + console.warn('[dpulse] Failed to unsubscribe from Feed Filter:', error); + } + } + if (decoder || feedDecoder) { console.log('[dpulse] Unsubscribed from Filter'); - } catch (error) { - console.warn('[dpulse] Failed to unsubscribe from Filter:', error); } } @@ -114,12 +130,16 @@ export async function createAndStartNode(): Promise { await waitForPeersWithRetry(node); console.log( - '[dpulse] Starting Store query and Filter subscription in parallel...', + '[dpulse] Starting Store queries and Filter subscription in parallel...', ); - await Promise.all([queryStoreHistory(node), setupFilterSubscription(node)]); + await Promise.all([ + queryStoreHistory(node), + queryFeedHistory(node), + setupFilterSubscription(node), + ]); - console.log('[dpulse] Store query and Filter subscription complete'); + console.log('[dpulse] Store queries and Filter subscription complete'); wakuNode = node; $connectionStatus.set('connected'); startPeerCountUpdates(); diff --git a/src/lib/dpulse/waku-store-client.ts b/src/lib/dpulse/waku-store-client.ts index 4880746..b67dd7c 100644 --- a/src/lib/dpulse/waku-store-client.ts +++ b/src/lib/dpulse/waku-store-client.ts @@ -1,5 +1,6 @@ import type { LightNode } from '@waku/sdk'; import { DPULSE_CONFIG, STORE_HISTORY_HOURS } from './constants'; +import { processFeedBatch } from './feed-processor'; import { processMessagePayload } from './message-processor'; export async function queryStoreHistory(node: LightNode): Promise { @@ -56,3 +57,58 @@ export async function queryStoreHistory(node: LightNode): Promise { return 0; } } + +export async function queryFeedHistory(node: LightNode): Promise { + const feedStoreDecoder = node.createDecoder({ + contentTopic: DPULSE_CONFIG.feedContentTopic, + }); + + const now = new Date(); + const startTime = new Date(now.getTime() - STORE_HISTORY_HOURS * 3600000); + + console.log( + `[dpulse] Querying Store for feed messages from ${startTime.toISOString()} to ${now.toISOString()}`, + ); + + const decodePromises: Promise[] = []; + + try { + await node.store.queryWithOrderedCallback( + [feedStoreDecoder], + (wakuMessage) => { + const payload = wakuMessage.payload; + if (!payload || payload.length === 0) { + return false; + } + + if (wakuMessage.timestamp) { + const msgTime = wakuMessage.timestamp; + if (msgTime < startTime || msgTime > now) { + return false; + } + } + + decodePromises.push(processFeedBatch(payload, 'store')); + + return false; + }, + { + paginationForward: false, + timeStart: startTime, + timeEnd: now, + }, + ); + + const results = await Promise.all(decodePromises); + const batchCount = results.filter(Boolean).length; + + console.log( + `[dpulse] Feed store query complete, processed ${batchCount} feed batches`, + ); + + return batchCount; + } catch (error) { + console.error('[dpulse] Feed store query failed:', error); + return 0; + } +} diff --git a/src/lib/pixelfed.ts b/src/lib/pixelfed.ts new file mode 100644 index 0000000..786e99c --- /dev/null +++ b/src/lib/pixelfed.ts @@ -0,0 +1,131 @@ +import { XMLParser } from 'fast-xml-parser'; + +const FEED_URL = 'https://pixelfed.social/users/xaviers.atom'; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', +}); + +export interface PixelFedFeed { + id: string; + title: string; + subtitle: string; + updated: string; + author: { + name: string; + uri: string; + }; + icon: string; + entries: PixelFedEntry[]; +} + +export interface PixelFedEntry { + id: string; + title: string; + summary: string; + content: string; + updated: string; + link: string; + images: PixelFedImage[]; +} + +export interface PixelFedImage { + url: string; + type: string; + alt?: string; +} + +interface RawAtomLink { + '@_href': string; + '@_rel'?: string; +} + +interface RawMediaContent { + '@_url': string; + '@_type': string; + '@_medium': string; +} + +interface RawAtomEntry { + id: string; + title: string; + summary?: string; + content?: string; + updated: string; + link: RawAtomLink | RawAtomLink[]; + 'media:content': RawMediaContent | RawMediaContent[]; +} + +export async function fetchPixelFedFeed(): Promise { + const response = await fetch(FEED_URL, { + next: { revalidate: 3600 }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch PixelFed feed: ${response.status}`); + } + + return response.text(); +} + +export function parsePixelFedFeed(xml: string): PixelFedFeed { + const parsed = parser.parse(xml); + const feed = parsed.feed; + + const rawEntries = feed.entry; + const entriesArray: RawAtomEntry[] = Array.isArray(rawEntries) + ? rawEntries + : rawEntries + ? [rawEntries] + : []; + + const entries: PixelFedEntry[] = entriesArray.map((entry: RawAtomEntry) => ({ + id: entry.id, + title: entry.title, + summary: entry.summary || '', + content: entry.content || '', + updated: entry.updated, + link: extractLink(entry.link), + images: extractImages(entry['media:content']), + })); + + return { + id: feed.id, + title: feed.title, + subtitle: feed.subtitle || '', + updated: feed.updated, + author: { + name: feed.author.name, + uri: feed.author.uri, + }, + icon: feed.icon || '', + entries, + }; +} + +export function extractLink(link: RawAtomLink | RawAtomLink[]): string { + if (!link) return ''; + if (Array.isArray(link)) { + const selfLink = link.find( + (l) => l['@_rel'] === 'self' || l['@_rel'] === 'alternate', + ); + return selfLink?.['@_href'] || link[0]?.['@_href'] || ''; + } + return link['@_href'] || ''; +} + +export function extractImages( + mediaContent: RawMediaContent | RawMediaContent[], +): PixelFedImage[] { + if (!mediaContent) return []; + + const items = Array.isArray(mediaContent) ? mediaContent : [mediaContent]; + + return items + .filter((item: RawMediaContent) => item['@_medium'] === 'image') + .map((item: RawMediaContent) => ({ + url: item['@_url'], + type: item['@_type'], + })); +} diff --git a/src/stores/terminal-store.ts b/src/stores/terminal-store.ts index 8a7249c..fe81275 100644 --- a/src/stores/terminal-store.ts +++ b/src/stores/terminal-store.ts @@ -130,7 +130,6 @@ export function setNextHistoryEntry() { export function autocomplete() { const input = $terminalInput.get(); const matchedCmds = Commands.map((cmd) => cmd.name).filter((cmd) => { - console.log(cmd); return cmd.startsWith(input); }); if (matchedCmds.length === 1) { diff --git a/src/types/terminal.ts b/src/types/terminal.ts index 59f3982..90579cc 100644 --- a/src/types/terminal.ts +++ b/src/types/terminal.ts @@ -1,10 +1,10 @@ import type { ComponentType } from 'react'; -import type { Translator } from '@/i18n/intl'; export enum Command { BuildInfo = 'build-info', Clear = 'clear', Contact = 'contact', + Gallery = 'gallery', Help = 'help', Status = 'status', Welcome = 'welcome', @@ -23,7 +23,6 @@ export interface CommandEntry { export interface CommandOutputProps { entry: CommandEntry; - t: Translator; } export type CommandOutput = ComponentType;