···11-# Ewan's Web Corner
11+# sv
2233-Welcome to the repository for **Ewan's Web Corner**, my personal website. This site is where I share my thoughts on coding, technology, and various aspects of my life.
33+Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
4455-## Site Overview
55+## Creating a project
6677-The site is built using **atproto** technology, and the structure is optimised to display posts, profile information, and various content in a user-friendly way. It's also designed to be easy to navigate, with pages like **About** and **Verification** accessible directly from the sidebar.
77+If you're seeing this, you've probably already done this step. Congrats!
8899-It is based on the fantastic work by [fei.chicory.blue](https://codeberg.org/fei-chicory-blue/atproto-site), just with my own tweaks.
99+```bash
1010+# create a new project in the current directory
1111+npx sv create
10121111-### Features
1212-1313-- **Customisable Layout:** The page layout features a left sidebar with profile info, navigation links, and pagination. The right column is dedicated to displaying posts.
1414-- **Posts:** The site supports multiple post types, including text posts, image posts, and embedded content. Posts can be customised with rich media, such as images and embedded cards.
1515-- **Profile Information:** The left sidebar includes a space for a profile picture, username, description, and external links.
1616-- **Mobile Optimisation:** The site is optimised for mobile browsing, with a responsive design that adjusts to different screen sizes.
1717-1818-For more details on customisation, refer to the documentation on WhiteWind [here](https://whtwnd.com/did:plc:xz3euvkhf44iadavovbsmqoo/3laxrz4dl4s2f).
1919-2020-### Powered by atproto
2121-2222-The site is powered by the [atproto platform](https://atproto.com), which enables decentralised content hosting. This allows you to integrate various platforms and manage your content seamlessly.
2323-2424-## Running the Site with Docker
2525-2626-To run the site using Docker Compose, follow these steps:
1313+# create a new project in my-app
1414+npx sv create my-app
1515+```
27162828-1. Build and start the Docker container:
1717+## Developing
29183030- ```sh
3131- docker compose up --build -d
3232- ```
1919+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
33203434-This will build your Docker image and start the website inside a Docker container, making it accessible on port `3002`.
2121+```bash
2222+npm run dev
35233636-## Deploying with Cloudflare Tunnel
2424+# or start the server and open the app in a new browser tab
2525+npm run dev -- --open
2626+```
37273838-To deploy the site through a Cloudflare Tunnel, follow these steps:
2828+## Building
39294040-### On macOS
3030+To create a production version of your app:
41314242-1. Install the Cloudflare Tunnel client (`cloudflared`):
3232+```bash
3333+npm run build
3434+```
43354444- ```sh
4545- brew install cloudflare/cloudflare/cloudflared
4646- ```
3636+You can preview the production build with `npm run preview`.
47374848-2. Authenticate `cloudflared` with your Cloudflare account:
4949-5050- ```sh
5151- cloudflared login
5252- ```
5353-5454-3. Create a tunnel and give it a name:
5555-5656- ```sh
5757- cloudflared tunnel create my-tunnel
5858- ```
5959-6060-4. Configure the tunnel to route traffic to your Docker container:
6161-6262- ```sh
6363- cloudflared tunnel route dns my-tunnel mywebsite.example.com
6464- ```
6565-6666-5. Start the tunnel and route traffic to your local Docker container running on port `3002`:
6767-6868- ```sh
6969- cloudflared tunnel --url http://localhost:3002 run my-tunnel
7070- ```
7171-7272-### On Ubuntu Server
7373-7474-1. Install the Cloudflare Tunnel client (`cloudflared`):
7575-7676- ```sh
7777- sudo apt-get update
7878- sudo apt-get install -y wget
7979- wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
8080- sudo dpkg -i cloudflared-linux-amd64.deb
8181- ```
8282-8383-2. Authenticate `cloudflared` with your Cloudflare account:
8484-8585- ```sh
8686- cloudflared login
8787- ```
8888-8989-3. Create a tunnel and give it a name:
9090-9191- ```sh
9292- cloudflared tunnel create my-tunnel
9393- ```
9494-9595-4. Configure the tunnel to route traffic to your Docker container:
9696-9797- ```sh
9898- cloudflared tunnel route dns my-tunnel mywebsite.example.com
9999- ```
100100-101101-5. Start the tunnel and route traffic to your local Docker container running on port `3002`:
102102-103103- ```sh
104104- cloudflared tunnel --url http://localhost:3002 run my-tunnel
105105- ```
3838+> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
···11-'use strict';
22-async function atprotoSite(options) {
33-44-// globalvar
55-66-let md;
77-if (window.markdownit) {
88- md = window.markdownit();
99-} else {
1010- // if the markdown parser isn't loaded, just make it text with linebreaks
1111- md = {
1212- render: (markdown) => {
1313- const p = document.createElement('p');
1414- p.innerText = markdown;
1515- return p.outerHTML;
1616- }
1717- }
1818-}
1919-2020-// html elements
2121-const refs = {
2222- scrollers: document.querySelectorAll('.atproto-site-scroll'),
2323- postContainer: document.querySelector('post-container'),
2424- pagination: document.querySelector('if-pagination'),
2525- ifPreviousPage: document.querySelector('if-previous-page'),
2626- ifNextPage: document.querySelector('if-next-page'),
2727- currentPageNumber:document.querySelector('current-page-number'),
2828- previousPageLink: wrapLink(document, '', 'previous-page-link'),
2929- nextPageLink: wrapLink(document, '', 'next-page-link')
3030-}
3131-// places to store loaded info
3232-const cache = {
3333- users: {},
3434- records: {},
3535- page: 1
3636-}
3737-const stream = {
3838- records: [],
3939- queues: []
4040-}
4141-function addQueue(user, collection, filter) {
4242- stream.queues.push({
4343- user: user,
4444- collection: collection,
4545- records: [],
4646- cursor: '',
4747- reachedEnd: false,
4848- filter: filter
4949- });
5050-}
5151-5252-// Modal
5353-5454-const modal = document.createElement('div');
5555-modal.style.position = 'fixed';
5656-modal.style.inset = '0';
5757-modal.style.display = 'flex';
5858-modal.style.flexDirection = 'column';
5959-modal.style.justifyContent = 'center';
6060-modal.style.alignItems = 'center';
6161-modal.style.pointerEvents = 'none';
6262-6363-const modalImage = document.createElement('img');
6464-modalImage.style.display = 'block';
6565-modalImage.style.width = 'auto';
6666-modalImage.style.height = 'auto';
6767-modalImage.style.maxHeight = '90%';
6868-modalImage.style.maxWidth = '90%';
6969-modalImage.style.pointerEvents = 'auto';
7070-modal.appendChild(modalImage);
7171-7272-const modalBackground = document.createElement('div');
7373-modalBackground.style.position = 'fixed';
7474-modalBackground.style.inset = '0';
7575-modalBackground.style.background = '#000000bb';
7676-modalBackground.onclick = function () {
7777- modal.remove();
7878- this.remove();
7979-}
8080-8181-const modalAlt = document.createElement('div');
8282-modalAlt.style.maxWidth = '90%';
8383-modalAlt.style.padding = '.5em';
8484-modalAlt.style.marginTop = '12px';
8585-modalAlt.style.background = '#222222';
8686-modalAlt.style.color = '#ffffff';
8787-modalAlt.style.border = '1px solid #555555';
8888-modalAlt.style.borderRadius = '6px';
8989-modalAlt.style.overflowY = 'auto';
9090-modalAlt.style.pointerEvents = 'auto';
9191-modalAlt.ariaHidden = 'true';
9292-modal.appendChild(modalAlt);
9393-9494-function openModal(src, alt) {
9595- modalImage.src = src;
9696- modalImage.alt = alt;
9797- modalAlt.textContent = alt;
9898- document.body.appendChild(modalBackground);
9999- document.body.appendChild(modal);
100100-}
101101-102102-// Sources
103103-104104-const sources = [
105105- {
106106- name: 'Bluesky',
107107- createQueue(user) {
108108- let filter = function (record) {
109109- if (!options.showReplies && record.value.reply) {
110110- return false;
111111- }
112112- if (options.hideNSFW) {
113113- // filters out all current self labels
114114- if (record.value.labels && record.value.labels.values) {
115115- for (let label of record.value.labels.values) {
116116- if (label.val === "porn" || label.val === "graphic-media" || label.val === "sexual" || label.val === "nudity") {
117117- return false;
118118- }
119119- }
120120- }
121121- }
122122- return true;
123123- }
124124- addQueue(user, 'app.bsky.feed.post', filter);
125125- },
126126- createPost(record, info) {
127127- if (info.collection === 'app.bsky.feed.post') {
128128- const url = `https://bsky.app/profile/${info.DID}/post/${info.rkey}`;
129129- let images = [];
130130-131131- // construct embed based on embed type
132132- let externalEmbed;
133133- let embed = record.value.embed;
134134- if (embed) {
135135- if (embed.$type === 'app.bsky.embed.images') {
136136- images = embed.images;
137137- } else if (embed.$type === 'app.bsky.embed.recordWithMedia') {
138138- if (embed.media.images) {
139139- images = embed.media.images;
140140- } else if (embed.media.video) {
141141- externalEmbed = { title: embed.alt, text: 'Open video on Bluesky', url: url };
142142- }
143143- } else if (embed.$type === 'app.bsky.embed.external') {
144144- externalEmbed = {
145145- title: embed.external.title,
146146- text: embed.external.description,
147147- url: embed.external.uri,
148148- user: record.author,
149149- blob: embed.external.thumb
150150- }
151151- } else if (embed.$type === 'app.bsky.embed.video') {
152152- externalEmbed = { title: embed.alt, text: 'Open video on Bluesky', url: url };
153153- } else {
154154- externalEmbed = { text: 'Open embedded content on Bluesky', url: url };
155155- }
156156- }
157157-158158- // pick type of post to create
159159- let post;
160160- if (images.length === 1) {
161161- post = createCustomElement('image-post');
162162- } else if (images.length > 1) {
163163- post = createCustomElement('imageset-post');
164164- } else {
165165- post = createCustomElement('text-post');
166166- }
167167-168168- // replies
169169- if (record.value.reply) {
170170- removeCustomElements(post, 'if-not-reply');
171171- const replyInfo = parseURI(record.value.reply.parent.uri);
172172- wrapLink(post, `https://bsky.app/profile/${replyInfo.DID}/post/${replyInfo.rkey}`, 'reply-link-wrapper');
173173- } else {
174174- removeCustomElements(post, 'if-reply');
175175- }
176176-177177- removeCustomElements(post, 'if-title');
178178- addText(post, record.value.text);
179179- for (let image of images) {
180180- addImage(post, record.author, image.image, image.alt);
181181- }
182182- if (externalEmbed) {
183183- addEmbed(post, externalEmbed);
184184- }
185185- addRemoteLink(post, url, 'Bluesky');
186186- addCreatedAt(post, record.value.createdAt);
187187- return post;
188188- }
189189- },
190190- },
191191- {
192192- name: 'WhiteWind',
193193- createQueue(user) {
194194- let filter;
195195- if (!options.showDrafts) {
196196- filter = (record) => record.value.visibility === 'public';
197197- }
198198- addQueue(user, 'com.whtwnd.blog.entry', filter);
199199- },
200200- createPost(record, info, threshold) {
201201- if (info.collection === 'com.whtwnd.blog.entry') {
202202- const post = createCustomElement('text-post');
203203- removeCustomElements(post, 'if-reply');
204204- addText(post, record.value.title, 'title-content');
205205- addMarkdown(post, record.value.content, threshold);
206206- addRemoteLink(post, `https://whtwnd.com/${info.DID}/${info.rkey}`, 'WhiteWind');
207207- addCreatedAt(post, record.value.createdAt);
208208- return post;
209209- }
210210- }
211211- },
212212- {
213213- name: 'Frontpage',
214214- createQueue(user) {
215215- addQueue(user, 'fyi.unravel.frontpage.post');
216216- },
217217- createPost(record, info) {
218218- if (info.collection === 'fyi.unravel.frontpage.post') {
219219- const post = createCustomElement('link-post');
220220- removeCustomElements(post, 'if-reply');
221221- addText(post, record.value.title, 'title-content');
222222- addText(post, record.value.url, 'link-content');
223223- wrapLink(post, record.value.url);
224224- addRemoteLink(post, `https://frontpage.fyi/post/${info.DID}/${info.rkey}`,'Frontpage');
225225- addCreatedAt(post, record.value.createdAt);
226226- return post;
227227- }
228228- }
229229- },
230230- {
231231- name: 'PinkSea',
232232- createQueue(user) {
233233- let filter = function (record) {
234234- if (!options.showReplies && record.value.inResponseTo) {
235235- return false;
236236- }
237237- if (options.hideNSFW && record.value.nsfw) {
238238- return false;
239239- }
240240- return true;
241241- }
242242- addQueue(user, 'com.shinolabs.pinksea.oekaki', filter);
243243- },
244244- createPost(record, info) {
245245- if (info.collection === 'com.shinolabs.pinksea.oekaki') {
246246- let post = createCustomElement('image-post');
247247-248248- // replies
249249- if (record.value.inResponseTo) {
250250- removeCustomElements(post, 'if-not-reply');
251251- const replyInfo = parseURI(record.value.inResponseTo.uri);
252252- wrapLink(post, `https://pinksea.art/${replyInfo.DID}/oekaki/${replyInfo.rkey}`, 'reply-link-wrapper');
253253- } else {
254254- removeCustomElements(post, 'if-reply');
255255- }
256256-257257- removeCustomElements(post, 'if-title');
258258- addImage(post, record.author, record.value.image.blob, record.value.image.imageLink.alt);
259259- addTags(post, record.value.tags);
260260- addRemoteLink(post, `https://pinksea.art/${info.DID}/oekaki/${info.rkey}`, 'PinkSea');
261261- addCreatedAt(post, record.value.createdAt);
262262- return post;
263263- }
264264- }
265265- },
266266- {
267267- name: 'pastesphere',
268268- createQueue(user) {
269269- addQueue(user, 'link.pastesphere.snippet');
270270- },
271271- createPost(record, info, threshold) {
272272- if (info.collection === 'link.pastesphere.snippet') {
273273- const post = createCustomElement('text-post');
274274- removeCustomElements(post, 'if-reply');
275275- addText(post, record.value.title, 'title-content');
276276- // put together description and body, wrap code types in backticks
277277- if (record.value.type === 'Plain Text') {
278278- addText(post, `${record.value.description}\n\n${record.value.body}`);
279279- } else if (record.value.type === 'Markdown') {
280280- addMarkdown(post, `${record.value.description}\n\n${record.value.body}`, threshold);
281281- } else {
282282- addMarkdown(post, `${record.value.description}\n\n\`\`\`${record.value.type}\n${record.value.body}\`\`\``, threshold);
283283- }
284284- addRemoteLink(post, `https://pastesphere.link/user/${info.DID}/snippet/${info.rkey}`, 'pastesphere');
285285- addCreatedAt(post, record.value.createdAt);
286286- return post;
287287- }
288288- }
289289- }
290290-]
291291-292292-// Things that deal with at://
293293-294294-// breaks down URI into DID, collection, and rkey
295295-function parseURI(uri) {
296296- const components = uri.split('/');
297297- return {
298298- DID: components[2],
299299- collection: components[3],
300300- rkey: components[4]
301301- }
302302-}
303303-304304-// returns a cursor and list of records or undefined
305305-async function fetchRecords(DID, collection, limit=20, cursor=undefined) {
306306- let url = `https://${options.PDS}/xrpc/com.atproto.repo.listRecords?repo=${DID}&collection=${collection}&limit=${limit}`;
307307- if (cursor) {
308308- url += `&cursor=${cursor}`;
309309- }
310310- const httpsResponse = await fetch(url, { method: 'GET' });
311311- if (!httpsResponse.ok) {
312312- return undefined; // handled differently depending on caller
313313- }
314314- const converted = await httpsResponse.json();
315315- return converted;
316316-}
317317-318318-// returns a single record or undefined
319319-async function fetchRecordByURI(uri) {
320320- const info = parseURI(uri);
321321- const url = `https://${options.PDS}/xrpc/com.atproto.repo.getRecord?repo=${info.DID}&collection=${info.collection}&rkey=${info.rkey}`;
322322- const httpsResponse = await fetch(url, { method: 'GET' });
323323- if (!httpsResponse.ok) {
324324- return undefined;
325325- }
326326- const converted = await httpsResponse.json();
327327- converted.author = cache.users[info.DID];
328328- return converted;
329329-}
330330-331331-// Loading
332332-333333-// adds up to a certain number of records to a queue
334334-async function queueRecords(queue, amount) {
335335- let records = [];
336336- let limit = amount;
337337- while (!records.length) {
338338- if (queue.reachedEnd) {
339339- break;
340340- }
341341-342342- const response = await fetchRecords(queue.user.DID, queue.collection, limit, queue.cursor);
343343- queue.cursor = response.cursor;
344344- if (queue.filter) {
345345- records = response.records.filter(queue.filter);
346346- } else {
347347- records = response.records;
348348- }
349349-350350- if (!response.cursor || response.records.length < amount) {
351351- queue.reachedEnd = true;
352352- }
353353- }
354354- queue.records = queue.records.concat(records);
355355-}
356356-357357-// loads one page worth of records from queues into stream
358358-async function loadRecords() {
359359- let records = [];
360360- const postsPerPage = options.postsPerPage;
361361- while (records.length < postsPerPage) {
362362- // remove any empty queues
363363- stream.queues = stream.queues.filter((queue) => {
364364- return queue.records.length;
365365- });
366366-367367- // stop loading if there are no queues
368368- if (!stream.queues.length) {
369369- break;
370370- }
371371-372372- // find the queue with the smallest first post date
373373- let queue;
374374- let latest = stream.queues[0];
375375- for (let i = 1; i < stream.queues.length; i++) {
376376- queue = stream.queues[i];
377377- if (queue.records[0].value.createdAt > latest.records[0].value.createdAt) {
378378- latest = queue;
379379- }
380380- }
381381- // unshift it into the new page of records
382382- const latestRecord = latest.records.shift();
383383- latestRecord.author = latest.user;
384384- records.push(latestRecord);
385385- // if that queue is empty, queue up a bit more than the page size
386386- if (!latest.records.length) {
387387- await queueRecords(latest, postsPerPage + options.overshoot);
388388- }
389389- }
390390- stream.records = stream.records.concat(records);
391391-}
392392-393393-// returns posts on a given page
394394-async function getPostsByPage(page) {
395395- const postsPerPage = options.postsPerPage;
396396- const pageStart = (page - 1) * postsPerPage;
397397- const pageEnd = pageStart + postsPerPage;
398398-399399- // load pages until we reach this page + 1 entry
400400- while (stream.records.length <= pageEnd) {
401401- await loadRecords();
402402- // if there is nothing more to load from, return whatever did load
403403- if (!stream.queues.length) {
404404- return stream.records.slice(pageStart, pageEnd);
405405- }
406406- }
407407- return stream.records.slice(pageStart, pageEnd);
408408-}
409409-410410-// loads up the page limit and returns all posts with the tag
411411-async function getPostsByTag(tag) {
412412- await getPostsByPage(options.pageLimit);
413413- const filteredPosts = stream.records.filter((post) => {
414414- if (post.value.tags) {
415415- for (let postTag of post.value.tags) {
416416- if (tag === postTag.toLowerCase()) {
417417- return true;
418418- }
419419- }
420420- }
421421- return false;
422422- });
423423- return filteredPosts;
424424-}
425425-426426-// returns a record given its URI
427427-async function getRecord(uri) {
428428- if (uri in cache.records) {
429429- return cache.records[uri];
430430- }
431431- // if not yet loaded, fetch it and add it
432432- const record = await fetchRecordByURI(uri);
433433- cache.records[uri] = record;
434434- return record;
435435-}
436436-437437-// Custom HTML Elements
438438-439439-function createCustomType(name) {
440440- customElements.define(name, class extends HTMLElement {});
441441-}
442442-const customTypes = [
443443- 'text-post',
444444- 'image-post',
445445- 'imageset-post',
446446- 'link-post',
447447- 'profile-username',
448448- 'profile-picture',
449449- 'profile-description',
450450- 'link-board',
451451- 'page-nav-container',
452452- 'page-nav',
453453- 'if-previous-page',
454454- 'previous-page-link',
455455- 'if-next-page',
456456- 'next-page-link',
457457- 'current-page',
458458- 'if-index-page',
459459- 'if-tag-page',
460460- 'tag-name',
461461- 'if-author',
462462- 'author-pfp',
463463- 'author-username',
464464- 'author-handle',
465465- 'if-title',
466466- 'title-content',
467467- 'text-content',
468468- 'image-content',
469469- 'link-wrapper',
470470- 'reply-link-wrapper',
471471- 'embed-container',
472472- 'embed-card',
473473- 'remote-link',
474474- 'created-at',
475475- 'tag-container',
476476- 'tag-chip',
477477- 'if-reply',
478478- 'if-not-reply'
479479-]
480480-for (let customType of customTypes) {
481481- createCustomType(customType);
482482-}
483483-484484-function createCustomElement(type, fallback=true) {
485485- const element = document.createElement(type);
486486-487487- let template = document.getElementById(`template-${type}`);
488488-489489- // for post types, fall back to generic-post
490490- if (!template && fallback) {
491491- template = document.getElementById('template-generic-post');
492492- }
493493-494494- // clone template or print an error and skip
495495- try {
496496- element.appendChild(template.content.cloneNode(true));
497497- } catch {
498498- console.error(`Could not find a valid template ${type}.`)
499499- }
500500-501501- return element;
502502-}
503503-504504-function showCustomElements(container, type) {
505505- const elements = container.querySelectorAll(type);
506506- for (let element of elements) {
507507- element.style.display = '';
508508- }
509509-}
510510-511511-function hideCustomElements(container, type) {
512512- const elements = container.querySelectorAll(type);
513513- for (let element of elements) {
514514- element.style.display = 'none';
515515- }
516516-}
517517-518518-function removeCustomElements(container, type) {
519519- const elements = container.querySelectorAll(type);
520520- for (let element of elements) {
521521- element.remove();
522522- }
523523-}
524524-525525-function clearContainer(container) {
526526- container.innerHTML = '';
527527-}
528528-529529-// General element creation
530530-531531-function createPost(record, showAuthor=true, threshold) {
532532- let post; // will be created depending on the type of post
533533- const info = parseURI(record.uri);
534534- for (let source of sources) {
535535- post = source.createPost(record, info, threshold);
536536- if (post) break;
537537- }
538538-539539- // Add author information
540540- if (showAuthor) {
541541- addAuthor(post, record.author);
542542- } else {
543543- removeCustomElements(post, 'if-author');
544544- }
545545-546546- // Tags as Classes
547547- if (options.tagsAsClasses) {
548548- if (record.value.tags) {
549549- for (let tag of record.value.tags) {
550550- post.classList.add(tag.toLowerCase());
551551- }
552552- }
553553- }
554554-555555- return post;
556556-}
557557-558558-// make an img element from blob or image link
559559-function createImage(user, blob, alt) {
560560- const imageElement = document.createElement('img');
561561- imageElement.alt = alt;
562562- // check if it's a blob or not
563563- if (blob && blob.ref) {
564564- if (options.blobURL) {
565565- imageElement.src = `${options.blobURL}/${user.DID}/${blob.ref.$link}`;
566566- } else {
567567- imageElement.src = `https://${options.PDS}/xrpc/com.atproto.sync.getBlob?did=${user.DID}&cid=${blob.ref.$link}`;
568568- }
569569- } else {
570570- imageElement.src = blob;
571571- }
572572- return imageElement;
573573-}
574574-575575-// container: HTML element
576576-// url: string
577577-// elementTag: string
578578-// returns the link element the content has been wrapped in
579579-function wrapLink(container, url, elementTag='link-wrapper') {
580580- const content = container.querySelector(elementTag);
581581- if (content) {
582582- const link = document.createElement('a');
583583- link.href = url;
584584- content.parentElement.replaceChild(link, content);
585585- link.appendChild(content);
586586- return link;
587587- }
588588-}
589589-590590-// Functions that add things to elements
591591-592592-// container: HTML element
593593-// records: array of records
594594-function addPosts(container, records) {
595595- for (let record of records) {
596596- let element = createPost(record, true, options.readMore);
597597- container.appendChild(element);
598598- }
599599-}
600600-601601-// container: HTML element
602602-// uri: AT URI
603603-// showAuthor: boolean
604604-async function addPostByURI(container, uri, showAuthor) {
605605- const record = await getRecord(uri);
606606- if (record) {
607607- const element = createPost(record, showAuthor);
608608- container.appendChild(element);
609609- } else {
610610- addNotice(container, `The record ${uri} could not be found on the PDS.`);
611611- }
612612-}
613613-614614-// container: HTML element
615615-// text: string
616616-function addNotice(container, text) {
617617- const post = createCustomElement('text-post');
618618- addText(post, text);
619619- removeCustomElements(post, 'if-title');
620620- removeCustomElements(post, 'if-reply');
621621- removeCustomElements(post, 'if-author');
622622- container.appendChild(post);
623623-}
624624-625625-// post: HTML element
626626-// elementTag: custom element tag
627627-// text: string
628628-function addText(post, text, elementTag='text-content') {
629629- const textElement = post.querySelector(elementTag);
630630- if (textElement) {
631631- textElement.innerText = text;
632632- }
633633-}
634634-635635-// post: HTML element
636636-// markdown: string
637637-// threshold: how many elements to add Read more line after
638638-// elementTag: custom element tag
639639-function addMarkdown(post, markdown, threshold, elementTag='text-content') {
640640- const markdownElement = post.querySelector(elementTag);
641641- if (markdownElement) {
642642- markdownElement.innerHTML = md.render(markdown);
643643- if (threshold) {
644644- const children = markdownElement.children;
645645- // move all children past the readmore threshold to Details
646646- if (children.length > threshold) {
647647- const details = document.createElement('details');
648648- const summary = document.createElement('summary');
649649- summary.textContent = 'Read more';
650650- details.appendChild(summary);
651651- while (children.length > threshold) {
652652- details.appendChild(children[threshold]);
653653- }
654654- markdownElement.appendChild(details);
655655- }
656656- }
657657- }
658658-}
659659-660660-// post: HTML element
661661-// user: options.users._
662662-// blob: blob
663663-// alt: string
664664-function addImage(post, user, blob, alt, clickToOpen=true) {
665665- const imageContent = post.querySelector('image-content');
666666- if (imageContent) {
667667- const image = createImage(user, blob, alt);
668668- imageContent.appendChild(image);
669669- // add onclick event that opens modal with this image
670670- if (clickToOpen) {
671671- if (options.blobURL) {
672672- image.onclick = () => {
673673- openModal(`${options.blobURL}/${user.DID}/${blob.ref.$link}`, alt);
674674- };
675675- } else {
676676- image.onclick = () => {
677677- openModal(`https://${options.PDS}/xrpc/com.atproto.sync.getBlob?did=${user.DID}&cid=${blob.ref.$link}`, alt);
678678- };
679679- }
680680- }
681681- }
682682-}
683683-684684-// post: HTML element
685685-// title, text, url: string
686686-// user: options.users._
687687-// blob: blob
688688-function addEmbed(post, {title, text, url, user, blob}) {
689689- const embedContainer = post.querySelector('embed-container');
690690- if (embedContainer) {
691691- const embedLink = document.createElement('a');
692692- const embedCard = createCustomElement('embed-card', false);
693693- if (title) {
694694- addText(embedCard, title, 'title-content');
695695- } else {
696696- removeCustomElements(embedCard, 'if-title');
697697- }
698698- addText(embedCard, text, 'text-content');
699699- if (blob) {
700700- addImage(embedCard, user, blob, title || text, false);
701701- }
702702- embedLink.href = url;
703703- embedLink.appendChild(embedCard);
704704- embedContainer.appendChild(embedLink);
705705- }
706706-}
707707-708708-// post: HTML element
709709-// tags: array of strings
710710-function addTags(post, tags) {
711711- const tagContainer = post.querySelector('tag-container');
712712- if (tagContainer) {
713713- for (let tag of tags) {
714714- const tagChip = createCustomElement('tag-chip', false);
715715- wrapLink(tagChip, `#tagged/${tag.toLowerCase()}`);
716716- addText(tagChip, tag, 'link-content');
717717- tagContainer.appendChild(tagChip);
718718- }
719719- }
720720-}
721721-722722-// post: HTML element
723723-// createdAt: string, https://en.wikipedia.org/wiki/ISO_8601
724724-function addCreatedAt(post, createdAt) {
725725- const dateElement = post.querySelector('created-at');
726726- if (dateElement) {
727727- const datetime = Date.parse(createdAt);
728728- dateElement.textContent = options.dateFormat.format(datetime);
729729- }
730730-}
731731-732732-// post: HTML element
733733-// user: options.users._
734734-// adds author username, handle, and pfp, linking to handle domain if requested
735735-function addAuthor(post, user) {
736736- const usernameElement = post.querySelector('author-username');
737737- const handleElement = post.querySelector('author-handle');
738738- const pfpElement = post.querySelector('author-pfp');
739739- if (usernameElement) {
740740- usernameElement.textContent = user.profile.username;
741741- }
742742- if (handleElement) {
743743- handleElement.textContent = user.handle;
744744- }
745745- if (pfpElement) {
746746- const pfpImage = createImage(user, user.profile.pfp, `${user.profile.username}'s pfp`);
747747- pfpElement.appendChild(pfpImage);
748748- }
749749- if (options.linkToUserHandles) {
750750- wrapLink(post, `https://${user.handle}`, 'author-username');
751751- wrapLink(post, `https://${user.handle}`, 'author-handle');
752752- wrapLink(post, `https://${user.handle}`, 'author-pfp');
753753- }
754754-}
755755-756756-// post: HTML element
757757-// url: string
758758-// service: string
759759-function addRemoteLink(post, url, service) {
760760- const remoteElement = post.querySelector('remote-link');
761761- if (remoteElement) {
762762- const remoteLink = document.createElement('a');
763763- remoteLink.href = url;
764764- remoteLink.target = '_blank'; // open in new tab
765765- remoteLink.textContent = `Open on ${service}`;
766766- remoteElement.appendChild(remoteLink);
767767- }
768768-}
769769-770770-// Pagination
771771-772772-function updatePagination() {
773773- if (refs.pagination) {
774774- const hasPreviousPage = cache.page !== 1; // previous page if not on page 1
775775- const hasNextPage = (cache.page * options.postsPerPage < stream.records.length) && (cache.page + 1 <= options.pageLimit); // next page if there are records after this page and next page would be within page limit
776776-777777- // update page number and page number links
778778- if (refs.currentPageNumber) {
779779- refs.currentPageNumber.textContent = cache.page;
780780- }
781781- if (refs.previousPageLink) {
782782- if (hasPreviousPage) {
783783- refs.previousPageLink.href = `#page/${cache.page - 1}`;
784784- } else {
785785- refs.previousPageLink.removeAttribute('href');
786786- }
787787- }
788788- if (refs.nextPageLink) {
789789- if (hasNextPage) {
790790- refs.nextPageLink.href = `#page/${cache.page + 1}`;
791791- } else {
792792- refs.nextPageLink.removeAttribute('href');
793793- }
794794- }
795795-796796- // only display things in <if-previous/next-page> blocks if the page exists
797797- if (refs.ifPreviousPage) {
798798- refs.ifPreviousPage.style.display = hasPreviousPage ? '' : 'none';
799799- }
800800- if (refs.ifNextPage) {
801801- refs.ifNextPage.style.display = hasNextPage ? '' : 'none';
802802- }
803803- refs.pagination.style.display = '';
804804- }
805805-}
806806-807807-function hidePagination() {
808808- if (refs.pagination) {
809809- refs.pagination.style.display = 'none';
810810- }
811811-}
812812-813813-// Routing
814814-815815-// numbered page: one page of the timeline
816816-async function goToPage(page) {
817817- cache.page = page;
818818- hideCustomElements(document.body, 'if-tag-page');
819819- showCustomElements(document.body, 'if-index-page');
820820- clearContainer(refs.postContainer);
821821- const records = await getPostsByPage(page);
822822- addPosts(refs.postContainer, records);
823823- updatePagination();
824824-}
825825-826826-// tag page: all posts up to limit, filtered by tag
827827-async function goToTagPage(tag) {
828828- document.title += ` | #${tag}`;
829829- hideCustomElements(document.body, 'if-index-page');
830830- // add tag text to all if-tag-page tag-name elements
831831- const tagPageElements = document.body.querySelectorAll('if-tag-page');
832832- for (let element of tagPageElements) {
833833- element.style = '';
834834- addText(element, tag, 'tag-name');
835835- }
836836- hidePagination();
837837- clearContainer(refs.postContainer);
838838- const records = await getPostsByTag(tag);
839839- addPosts(refs.postContainer, records);
840840-}
841841-842842-// custom page: the specified post
843843-async function goToCustomPage(url) {
844844- hideCustomElements(document.body, 'if-tag-page');
845845- hideCustomElements(document.body, 'if-index-page');
846846- const page = options.pages[url];
847847- document.title += ` | ${page.title}`;
848848- hidePagination();
849849-850850- // add the new page as a post
851851- clearContainer(refs.postContainer);
852852- await addPostByURI(refs.postContainer, page.post, false);
853853-}
854854-855855-async function handleURL(url) {
856856- document.title = options.title;
857857-858858- const parts = url.split('/');
859859-860860- // figure out type of page and run the function for that type
861861- if (parts.length > 1 && parts[0] === 'page') {
862862- // #page/${number}
863863- const page = parseInt(parts[1]) || 1; // if no page number go to 1
864864- if (page <= options.pageLimit) {
865865- await goToPage(page);
866866- }
867867- } else if (parts.length > 1 && parts[0] === 'tagged') {
868868- // #tagged/${tag}
869869- await goToTagPage(parts[1]);
870870- } else if (url in options.pages) {
871871- // #${name} that matches a page in options.pages
872872- await goToCustomPage(url);
873873- } else if (url) {
874874- // error: page not found
875875- clearContainer(refs.postContainer);
876876- addNotice(refs.postContainer, `The page ${url} could not be found.`);
877877- hidePagination();
878878- } else {
879879- // default: timeline page 1
880880- await goToPage(1);
881881- }
882882-883883- for (let scroller of refs.scrollers) {
884884- scroller.scroll(0, 0);
885885- }
886886-}
887887-888888-window.onhashchange = () => {
889889- const url = window.location.hash.slice(1).toLowerCase();
890890-891891- // if we navigated to the home page, reload
892892- if (!url) {
893893- window.location.reload();
894894- } else {
895895- handleURL(url);
896896- }
897897-}
898898-899899-// page setup for first time load
900900-async function firstLoadSetup() {
901901- // defaults if not provided
902902- if (!options.pageLimit) options.pageLimit = 5;
903903- if (!options.postsPerPage) options.postsPerPage = 10;
904904- if (!options.overshoot) options.overshoot = options.postsPerPage;
905905- if (!options.users) options.users = [];
906906- if (!options.pages) options.pages = {};
907907- if (!options.dateFormat) options.dateFormat = new Intl.DateTimeFormat();
908908-909909- for (let user of options.users) {
910910- // set up queues
911911- for (let source of sources) {
912912- if (user.sources[source.name]) {
913913- source.createQueue(user);
914914- }
915915- }
916916- if (!user.profile) {
917917- // bluesky profile default
918918- const response = await fetchRecordByURI(`at://${user.DID}/app.bsky.actor.profile/self`);
919919- if (response) {
920920- user.profile = {
921921- username: response.value.displayName,
922922- description: response.value.description,
923923- pfp: response.value.avatar
924924- };
925925- } else {
926926- user.profile = {
927927- username: `Could not load profile for DID ${user.DID}`,
928928- description: `Could not load profile for DID ${user.DID}`,
929929- pfp: ''
930930- }
931931- }
932932- }
933933- // add user to cache
934934- cache.users[user.DID] = user;
935935- }
936936-937937- // Populate profile elements
938938- const usernameElement = document.querySelector('profile-username');
939939- const pfpElement = document.querySelector('profile-picture');
940940- const descriptionElement = document.querySelector('profile-description');
941941- const firstUser = options.users[0];
942942- let profile;
943943- if (usernameElement || pfpElement || descriptionElement) {
944944- profile = options.profile || firstUser.profile;
945945- }
946946- if (usernameElement) {
947947- usernameElement.textContent = profile.username;
948948- }
949949- if (descriptionElement) {
950950- descriptionElement.innerText = profile.description;
951951- }
952952- if (pfpElement) {
953953- const profilePicture = createImage(firstUser, profile.pfp, 'Site profile picture');
954954- pfpElement.appendChild(profilePicture);
955955- }
956956-957957- // Populate links
958958- if (firstUser && firstUser.sources.Linkat) {
959959- const linkBoard = document.querySelector('link-board');
960960- const response = await fetchRecordByURI(`at://${firstUser.DID}/blue.linkat.board/self`);
961961- if (response) {
962962- const cards = response.value.cards;
963963- for (let card of cards) {
964964- const linkCard = document.createElement('a');
965965- linkCard.href = card.url;
966966- const linkEmoji = document.createElement('span');
967967- linkEmoji.textContent = card.emoji;
968968- const linkText = document.createElement('span');
969969- linkText.textContent = card.text;
970970- linkCard.appendChild(linkEmoji);
971971- linkCard.appendChild(linkText);
972972- linkBoard.appendChild(linkCard);
973973- }
974974- } else {
975975- linkBoard.textContent = 'Could not find Linkat board.';
976976- }
977977- }
978978-979979- // Populate user-added posts on page
980980- const userAddedPosts = document.querySelectorAll('site-post');
981981- for (let sitePost of userAddedPosts) {
982982- const uri = sitePost.dataset.uri;
983983- if (uri) {
984984- const showAuthor = sitePost.dataset.showAuthor !== undefined;
985985- await addPostByURI(sitePost, sitePost.dataset.uri, showAuthor);
986986- } else {
987987- addNotice(sitePost, 'Please specify an AT URI.');
988988- }
989989- }
990990-991991- // go to page
992992- const url = window.location.hash.slice(1).toLowerCase();
993993- if (!(url in options.pages)) {
994994- // initial queue population for tl pages
995995- for (let queue of stream.queues) {
996996- await queueRecords(queue, options.postsPerPage + options.overshoot);
997997- }
998998- }
999999- await handleURL(url);
10001000-}
10011001-10021002-await firstLoadSetup();
10031003-10041004-}
-19
assets/scripts/copyright.js
···11-document.addEventListener("DOMContentLoaded", function () {
22- console.debug("Initialising copyright year update...");
33-44- function updateCopyrightYear() {
55- const currentYear = new Date().getFullYear();
66- const displayYear = currentYear < 2023 ? 2023 : currentYear;
77- const copyrightElement = document.getElementById("copyright-year");
88-99- if (copyrightElement) {
1010- copyrightElement.textContent = currentYear;
1111- console.debug(`Copyright year updated to ${currentYear}`);
1212- } else {
1313- console.warn("Copyright year element not found.");
1414- }
1515- }
1616-1717- // Update the copyright year on page load
1818- updateCopyrightYear();
1919- });
-67
assets/scripts/post-transitions.js
···11-document.addEventListener("DOMContentLoaded", function() {
22- // Set up an observer to watch for new posts being added to the DOM
33- const postContainer = document.querySelector('post-container');
44-55- if (!postContainer) {
66- console.warn("Post container not found");
77- return;
88- }
99-1010- // Function to add transition classes to posts
1111- function setupPostTransitions() {
1212- // Get all posts that don't have the transition class yet
1313- const posts = document.querySelectorAll('.post:not(.has-transition)');
1414-1515- posts.forEach((post, index) => {
1616- // Mark this post as having transitions
1717- post.classList.add('has-transition', 'post-transition');
1818-1919- // Set initial state (invisible)
2020- post.style.opacity = '0';
2121- post.style.transform = 'translateY(20px)';
2222-2323- // Stagger the appearance of posts
2424- setTimeout(() => {
2525- post.style.opacity = '1';
2626- post.style.transform = 'translateY(0)';
2727- }, 100 * index); // Stagger each post by 100ms
2828- });
2929- }
3030-3131- // Initial setup for any posts that are already in the DOM
3232- setupPostTransitions();
3333-3434- // Watch for changes in the post container (new posts being added)
3535- const observer = new MutationObserver((mutations) => {
3636- mutations.forEach((mutation) => {
3737- if (mutation.addedNodes.length > 0) {
3838- setupPostTransitions();
3939- }
4040- });
4141- });
4242-4343- // Start observing the post container for added posts
4444- observer.observe(postContainer, { childList: true, subtree: true });
4545-4646- // Handle page navigation (when the user navigates between pages)
4747- window.addEventListener('hashchange', function() {
4848- // Fade out all existing posts
4949- const posts = document.querySelectorAll('.post');
5050- posts.forEach((post, index) => {
5151- post.style.opacity = '0';
5252- post.style.transform = 'translateY(20px)';
5353- });
5454-5555- // After posts fade out, new ones will be loaded and fade in due to the observer
5656- setTimeout(setupPostTransitions, 400); // Wait for fade out to complete
5757- });
5858-5959- // Handle theme changes to reapply transitions
6060- const themeToggle = document.getElementById('theme-toggle');
6161- if (themeToggle) {
6262- themeToggle.addEventListener('click', function() {
6363- // Brief delay to allow theme change to process
6464- setTimeout(setupPostTransitions, 100);
6565- });
6666- }
6767-});
···11+// place files you want to import through the `$lib` alias in this folder.
+6
src/routes/+layout.svelte
···11+<script lang="ts">
22+ import '../app.css';
33+ let { children } = $props();
44+</script>
55+66+{@render children()}
+2
src/routes/+page.svelte
···11+<h1>Welcome to SvelteKit</h1>
22+<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
+18
svelte.config.js
···11+import adapter from '@sveltejs/adapter-auto';
22+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
33+44+/** @type {import('@sveltejs/kit').Config} */
55+const config = {
66+ // Consult https://svelte.dev/docs/kit/integrations
77+ // for more information about preprocessors
88+ preprocess: vitePreprocess(),
99+1010+ kit: {
1111+ // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
1212+ // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
1313+ // See https://svelte.dev/docs/kit/adapters for more information about adapters.
1414+ adapter: adapter()
1515+ }
1616+};
1717+1818+export default config;
+19
tsconfig.json
···11+{
22+ "extends": "./.svelte-kit/tsconfig.json",
33+ "compilerOptions": {
44+ "allowJs": true,
55+ "checkJs": true,
66+ "esModuleInterop": true,
77+ "forceConsistentCasingInFileNames": true,
88+ "resolveJsonModule": true,
99+ "skipLibCheck": true,
1010+ "sourceMap": true,
1111+ "strict": true,
1212+ "moduleResolution": "bundler"
1313+ }
1414+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
1515+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
1616+ //
1717+ // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
1818+ // from the referenced tsconfig.json - TypeScript does not merge them in
1919+}
+7
vite.config.ts
···11+import tailwindcss from '@tailwindcss/vite';
22+import { sveltekit } from '@sveltejs/kit/vite';
33+import { defineConfig } from 'vite';
44+55+export default defineConfig({
66+ plugins: [tailwindcss(), sveltekit()]
77+});