your personal website on atproto - mirror blento.app

add livestream and embed card

Florian cbd77fb9 8db0106f

+327 -54
+4 -12
README.md
··· 6 6 7 7 made with svelte, tailwind. 8 8 9 - ## Selfhosting with cloudflare workers 9 + ## Selfhosting 10 10 11 - - fork this repo 12 - - create a cloudflare worker application and connect it to your fork 13 - - change the vars in `wrangler.jsonc` 11 + See [docs/Selfhosting](./docs/Selfhosting.md). 14 12 15 - ```json 16 - "vars": { 17 - "PUBLIC_HANDLE": "your-bluesky-handle", 18 - "PUBLIC_IS_SELFHOSTED": "true", 19 - "PUBLIC_DOMAIN": "https://your-cloudflare-worker-or-custom-domain.com" 20 - } 21 - ``` 13 + ## Making Custom cards 22 14 23 - DONE :) your blento should be live after a minute or two at `your-cloudflare-worker-or-custom-domain.com` and you can edit it by signing in with your bluesky account at `your-cloudflare-worker-or-custom-domain.com/edit` 15 + See [docs/CustomCards](./docs/CustomCards.md)
+21
docs/CustomCards.md
··· 1 + # Custom Cards 2 + 3 + WORK IN PROGRESS, EARLY STATE, MIGHT CHANGE. 4 + 5 + see `src/lib/cards` for how cards are made. 6 + 7 + Current card definition: 8 + 9 + ```ts 10 + export type CardDefinition = { 11 + type: string; 12 + contentComponent: Component<ContentComponentProps>; // this is what your card shows 13 + 14 + editingContentComponent?: Component<ContentComponentProps>; // if this is not given, defaults to showing contentComponent in edit mode too 15 + creationModalComponent?: Component<CreationModalComponentProps>; // if this is not given will just add a card 16 + 17 + createNew?: (item: Item) => void; // this is run before the card is added, set some settings here 18 + 19 + sidebarComponent?: Component<SidebarComponentProps>; // this is the button that will be shown in the sidebar to add your card 20 + }; 21 + ```
+15
docs/Selfhosting.md
··· 1 + # Selfhosting with cloudflare workers 2 + 3 + - fork this repo 4 + - create a cloudflare worker application and connect it to your fork 5 + - change the vars in `wrangler.jsonc` (including https:// in the PUBLIC_DOMAIN var!) 6 + 7 + ```json 8 + "vars": { 9 + "PUBLIC_HANDLE": "your-bluesky-handle", 10 + "PUBLIC_IS_SELFHOSTED": "true", 11 + "PUBLIC_DOMAIN": "https://your-cloudflare-worker-or-custom-domain.com" 12 + } 13 + ``` 14 + 15 + DONE :) your blento should be live after a minute or two at `your-cloudflare-worker-or-custom-domain.com` and you can edit it by signing in with your bluesky account at `your-cloudflare-worker-or-custom-domain.com/edit`
+1
package.json
··· 48 48 "@foxui/colors": "^0.4.7", 49 49 "@foxui/core": "^0.4.7", 50 50 "@foxui/social": "^0.4.7", 51 + "@foxui/time": "^0.4.7", 51 52 "@tailwindcss/typography": "^0.5.16", 52 53 "@tiptap/core": "^2.12.0", 53 54 "@tiptap/extension-document": "^2.12.0",
+3
pnpm-lock.yaml
··· 29 29 '@foxui/social': 30 30 specifier: ^0.4.7 31 31 version: 0.4.7(svelte@5.45.8)(tailwindcss@4.1.5) 32 + '@foxui/time': 33 + specifier: ^0.4.7 34 + version: 0.4.7(svelte@5.45.8)(tailwindcss@4.1.5) 32 35 '@tailwindcss/typography': 33 36 specifier: ^0.5.16 34 37 version: 0.5.16(tailwindcss@4.1.5)
+7 -5
src/lib/EditableWebsite.svelte
··· 63 63 64 64 setIsMobile(() => isMobile); 65 65 66 - setCanEdit(() => client.isLoggedIn && client.profile?.did === did); 66 + setCanEdit(() => dev || (client.isLoggedIn && client.profile?.did === did)); 67 67 68 68 // svelte-ignore state_referenced_locally 69 69 setDidContext(did); ··· 329 329 </div> 330 330 331 331 <Sidebar mobileOnly mobileClasses="lg:block p-4"> 332 - {#each sidebarItems as cardDef} 333 - <cardDef.sidebarComponent onclick={() => newCard(cardDef.type)} /> 334 - {/each} 332 + <div> 333 + {#each sidebarItems as cardDef} 334 + <cardDef.sidebarComponent onclick={() => newCard(cardDef.type)} /> 335 + {/each} 336 + </div> 335 337 </Sidebar> 336 338 337 - {#if (!client.isLoggedIn && !client.isInitializing) || client.profile?.did === did} 339 + {#if dev || (!client.isLoggedIn && !client.isInitializing) || client.profile?.did === did} 338 340 <Navbar 339 341 class={[ 340 342 'dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto lg:inline-flex',
+23 -21
src/lib/cards/BaseCard/BaseCard.svelte
··· 6 6 import type { HTMLAttributes } from 'svelte/elements'; 7 7 8 8 const colors = { 9 - 'base': 'border-base-200 bg-base-50 dark:border-base-800 dark:bg-base-900 border', 10 - 'accent': 'border-accent-200 bg-accent-50 dark:border-accent-900/50 dark:bg-accent-950/50 border', 11 - 'transparent': '' 9 + base: 'border-base-200 bg-base-50 dark:border-base-800 dark:bg-base-900 border', 10 + accent: 'border-accent-200 bg-accent-50 dark:border-accent-900/50 dark:bg-accent-950/50 border', 11 + transparent: '' 12 12 } as Record<string, string>; 13 13 14 14 export type BaseCardProps = { ··· 33 33 bind:this={ref} 34 34 draggable={isEditing} 35 35 class={[ 36 - 'card group focus-within:outline-accent-500 absolute z-0 rounded-2xl outline-offset-2 focus-within:outline-2', 37 - item.color ? colors[item.color] ?? colors.accent : colors.base, 38 - item.color !== 'accent' && item.color !== 'base' && item.color !== 'transparent' ? item.color : '' 36 + 'card group focus-within:outline-accent-500 @container/card absolute z-0 rounded-2xl outline-offset-2 focus-within:outline-2', 37 + item.color ? (colors[item.color] ?? colors.accent) : colors.base, 38 + item.color !== 'accent' && item.color !== 'base' && item.color !== 'transparent' 39 + ? item.color 40 + : '' 39 41 ]} 40 42 style={` 41 - --mx: ${item.mobileX}; 42 - --my: ${item.mobileY}; 43 - --mw: ${item.mobileW}; 44 - --mh: ${item.mobileH}; 43 + --mx: ${item.mobileX * 2}; 44 + --my: ${item.mobileY * 2}; 45 + --mw: ${item.mobileW * 2}; 46 + --mh: ${item.mobileH * 2}; 45 47 --mm: ${mobileMargin}px; 46 48 47 - --dx: ${item.x}; 48 - --dy: ${item.y}; 49 - --dw: ${item.w}; 50 - --dh: ${item.h}; 49 + --dx: ${item.x * 2}; 50 + --dy: ${item.y * 2}; 51 + --dw: ${item.w * 2}; 52 + --dh: ${item.h * 2}; 51 53 --dm: ${margin}px;`} 52 54 {...rest} 53 55 > ··· 59 61 60 62 <style> 61 63 .card { 62 - translate: calc((var(--mx) / 4) * 100cqw + var(--mm)) calc((var(--my) / 4) * 100cqw + var(--mm)); 63 - width: calc((var(--mw) / 4) * 100cqw - (var(--mm) * 2)); 64 - height: calc((var(--mh) / 4) * 100cqw - (var(--mm) * 2)); 64 + translate: calc((var(--mx) / 8) * 100cqw + var(--mm)) calc((var(--my) / 8) * 100cqw + var(--mm)); 65 + width: calc((var(--mw) / 8) * 100cqw - (var(--mm) * 2)); 66 + height: calc((var(--mh) / 8) * 100cqw - (var(--mm) * 2)); 65 67 } 66 68 67 69 @container wrapper (width >= 64rem) { 68 70 .card { 69 - translate: calc((var(--dx) / 4) * 100cqw + var(--dm)) 70 - calc((var(--dy) / 4) * 100cqw + var(--dm)); 71 - width: calc((var(--dw) / 4) * 100cqw - (var(--dm) * 2)); 72 - height: calc((var(--dh) / 4) * 100cqw - (var(--dm) * 2)); 71 + translate: calc((var(--dx) / 8) * 100cqw + var(--dm)) 72 + calc((var(--dy) / 8) * 100cqw + var(--dm)); 73 + width: calc((var(--dw) / 8) * 100cqw - (var(--dm) * 2)); 74 + height: calc((var(--dh) / 8) * 100cqw - (var(--dm) * 2)); 73 75 } 74 76 } 75 77 </style>
+6 -1
src/lib/cards/BlueskyPostCard/SidebarItemBlueskyPostCard.svelte
··· 5 5 </script> 6 6 7 7 <Button {onclick} variant="ghost" class="w-full" size="lg"> 8 - <svg xmlns="http://www.w3.org/2000/svg" version="1.1" class="size-4" viewBox="0 0 600 530"> 8 + <svg 9 + xmlns="http://www.w3.org/2000/svg" 10 + version="1.1" 11 + class="text-accent-600 dark:text-accent-400 size-4" 12 + viewBox="0 0 600 530" 13 + > 9 14 <path 10 15 d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" 11 16 fill="currentColor"
+40
src/lib/cards/EmbedCard/CreateEmbedCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let errorMessage = $state(''); 8 + 9 + async function checkUrl() { 10 + errorMessage = ''; 11 + try { 12 + const domain = new URL(item.cardData.href); 13 + } catch (error) { 14 + errorMessage = 'Invalid URL!'; 15 + return false; 16 + } 17 + 18 + return true; 19 + } 20 + </script> 21 + 22 + <Modal open={true} closeButton={false}> 23 + <Subheading>Enter a link to embed</Subheading> 24 + <Input bind:value={item.cardData.href} /> 25 + 26 + {#if errorMessage} 27 + <Alert type="error" title="Failed to create embed card"><span>{errorMessage}</span></Alert> 28 + {/if} 29 + 30 + <div class="mt-4 flex justify-end gap-2"> 31 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 32 + <Button 33 + onclick={async () => { 34 + if (await checkUrl()) oncreate(); 35 + }} 36 + > 37 + Create</Button 38 + > 39 + </div> 40 + </Modal>
+13
src/lib/cards/EmbedCard/EmbedCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '../types'; 3 + 4 + let { item }: ContentComponentProps = $props(); 5 + </script> 6 + 7 + <iframe 8 + src={item.cardData.href} 9 + sandbox="allow-scripts" 10 + referrerpolicy="no-referrer" 11 + class="absolute inset-0 h-full w-full" 12 + title="" 13 + ></iframe>
+24
src/lib/cards/EmbedCard/SidebarItemEmbedCard.svelte
··· 1 + <script lang="ts"> 2 + import { Button } from '@foxui/core'; 3 + 4 + let { onclick }: { onclick: () => void } = $props(); 5 + </script> 6 + 7 + <Button {onclick} variant="ghost" class="w-full" size="lg"> 8 + <svg 9 + xmlns="http://www.w3.org/2000/svg" 10 + fill="none" 11 + viewBox="0 0 24 24" 12 + stroke-width="1.5" 13 + stroke="currentColor" 14 + class="text-accent-600 dark:text-accent-400" 15 + > 16 + <path 17 + stroke-linecap="round" 18 + stroke-linejoin="round" 19 + d="M14.25 9.75 16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" 20 + /> 21 + </svg> 22 + 23 + iFrame Embed</Button 24 + >
+17
src/lib/cards/EmbedCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import CreateEmbedCardModal from './CreateEmbedCardModal.svelte'; 3 + import EmbedCard from './EmbedCard.svelte'; 4 + import SidebarItemEmbedCard from './SidebarItemEmbedCard.svelte'; 5 + 6 + export const EmbedCardDefinition = { 7 + type: 'embed', 8 + contentComponent: EmbedCard, 9 + creationModalComponent: CreateEmbedCardModal, 10 + sidebarComponent: SidebarItemEmbedCard, 11 + createNew: (card) => { 12 + card.w = 2; 13 + card.h = 2; 14 + card.mobileH = 4; 15 + card.mobileW = 4; 16 + } 17 + } as CardDefinition & { type: 'embed' };
+23
src/lib/cards/LivestreamCard/Icon.svelte
··· 1 + <script lang="ts"> 2 + let { class: className } = $props(); 3 + </script> 4 + 5 + <svg 6 + class={['icon-box', className]} 7 + viewBox="0 0 24 24" 8 + fill="none" 9 + xmlns="http://www.w3.org/2000/svg" 10 + > 11 + <path 12 + d="M11.9916 22.0341L12.0261 8.22875L21.5 5.31238L21.1203 17.5991L11.9916 22.0341Z" 13 + class="fill-accent-500" 14 + /> 15 + <path 16 + d="M2.46593 17.858L11.9916 22.0341L12.0261 8.22876L2 5.45044L2.46593 17.858Z" 17 + class="fill-accent-300" 18 + /> 19 + <path 20 + d="M21.5 5.31239L12.0261 8.22876L2 5.45044L11.6292 3L21.5 5.31239Z" 21 + class="fill-accent-200" 22 + /> 23 + </svg>
+87
src/lib/cards/LivestreamCard/LivestreamCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import Icon from './Icon.svelte'; 4 + import { getDidContext } from '$lib/website/context'; 5 + import { listRecords } from '$lib/oauth/atproto'; 6 + import { getIsMobile } from '$lib/helper'; 7 + import type { ContentComponentProps } from '../types'; 8 + import { getImageBlobUrl } from '$lib/website/utils'; 9 + import { RelativeTime } from '@foxui/time'; 10 + 11 + let { item = $bindable() }: ContentComponentProps = $props(); 12 + 13 + let did = getDidContext(); 14 + 15 + let isMobile = getIsMobile(); 16 + 17 + let isLoaded = $state(false); 18 + 19 + let latestLivestream: 20 + | { 21 + createdAt: string; 22 + title: string; 23 + thumb?: string; 24 + href: string; 25 + } 26 + | undefined = $state(); 27 + 28 + onMount(async () => { 29 + const records = await listRecords({ did, collection: 'place.stream.livestream', limit: 3 }); 30 + console.log(records); 31 + 32 + const values = Object.values(records); 33 + if (values?.length > 0) { 34 + const latest = JSON.parse(JSON.stringify(values[0])); 35 + console.log(latest); 36 + 37 + latestLivestream = { 38 + createdAt: latest.value.createdAt, 39 + title: latest.value.title as string, 40 + thumb: getImageBlobUrl({ link: latest.value.thumb?.ref.$link, did }), 41 + href: latest.value.canonicalUrl || latest.value.url 42 + }; 43 + } 44 + 45 + isLoaded = true; 46 + }); 47 + </script> 48 + 49 + <div class="h-full overflow-y-scroll p-4"> 50 + {#if latestLivestream} 51 + <div class="flex min-h-full flex-col justify-between"> 52 + <div> 53 + <div class="mb-4 flex items-center gap-2"> 54 + <Icon class="size-6" /> 55 + <div class="font-semibold">Latest Livestream</div> 56 + </div> 57 + 58 + <div class="mb-2 text-xs font-medium"> 59 + started <RelativeTime date={new Date(latestLivestream.createdAt)} locale="en-US" /> ago 60 + </div> 61 + 62 + <a href={latestLivestream?.href} target="_blank" rel="noopener noreferrer"> 63 + <div 64 + class="text-accent-700 dark:text-accent-300 hover:text-accent-600 dark:hover:text-accent-400 text-xl font-semibold transition-colors duration-150" 65 + > 66 + {latestLivestream?.title} 67 + </div> 68 + </a> 69 + </div> 70 + 71 + {#if ((isMobile() && item.mobileH >= 4) || (!isMobile() && item.h >= 2)) && latestLivestream?.thumb} 72 + <a href={latestLivestream?.href} target="_blank" rel="noopener noreferrer"> 73 + <img 74 + class="my-4 max-h-32 w-full rounded-xl object-cover" 75 + src={latestLivestream?.thumb} 76 + alt="" 77 + /> 78 + <span class="sr-only">open livestream</span> 79 + </a> 80 + {/if} 81 + </div> 82 + {:else if isLoaded} 83 + <div class="flex h-full w-full items-center justify-center">No latest stream found!</div> 84 + {:else} 85 + <div class="flex h-full w-full items-center justify-center">Looking for the latest stream</div> 86 + {/if} 87 + </div>
+12
src/lib/cards/LivestreamCard/SidebarItemLivestreamCard.svelte
··· 1 + <script lang="ts"> 2 + import { Button } from '@foxui/core'; 3 + import Icon from './Icon.svelte'; 4 + 5 + let { onclick }: { onclick: () => void } = $props(); 6 + </script> 7 + 8 + <Button {onclick} variant="ghost" class="w-full" size="lg"> 9 + <Icon class="size-4" /> 10 + 11 + Latest Livestream 12 + </Button>
+15
src/lib/cards/LivestreamCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import LivestreamCard from './LivestreamCard.svelte'; 3 + import SidebarItemLivestreamCard from './SidebarItemLivestreamCard.svelte'; 4 + 5 + export const LivestreamCardDefitition = { 6 + type: 'latestLivestream', 7 + contentComponent: LivestreamCard, 8 + sidebarComponent: SidebarItemLivestreamCard, 9 + createNew: (card) => { 10 + card.w = 2; 11 + card.h = 1; 12 + card.mobileH = 2; 13 + card.mobileW = 4; 14 + } 15 + } as CardDefinition & { type: 'latestLivestream' };
+1 -5
src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte
··· 12 12 let profiles: ProfileViewDetailed[] = $state([]); 13 13 14 14 onMount(async () => { 15 - console.log(recentRecords); 16 15 let uniqueDids = new Set<string>(); 17 16 for (let record of recentRecords as { did: string }[]) { 18 17 uniqueDids.add(record.did); 19 18 } 20 - console.log(uniqueDids, Array.from(uniqueDids)); 21 19 22 20 for (let did of Array.from(uniqueDids)) { 23 - console.log(did); 24 21 const profile = await getProfile({ did }); 25 22 profiles.push(profile); 26 - 27 - if (profiles.length > 9) return; 23 + if (profiles.length > 20) return; 28 24 } 29 25 }); 30 26 </script>
+5 -1
src/lib/cards/index.ts
··· 1 1 import { BlueskyPostCardDefinition } from './BlueskyPostCard'; 2 + import { EmbedCardDefinition } from './EmbedCard'; 2 3 import { ImageCardDefinition } from './ImageCard'; 3 4 import { LinkCardDefinition } from './LinkCard'; 5 + import { LivestreamCardDefitition } from './LivestreamCard'; 4 6 import { UpdatedBlentosCardDefitition } from './SpecialCards/UpdatedBlentos'; 5 7 import { TextCardDefinition } from './TextCard'; 6 8 import type { CardDefinition } from './types'; ··· 12 14 LinkCardDefinition, 13 15 UpdatedBlentosCardDefitition, 14 16 YoutubeCardDefinition, 15 - BlueskyPostCardDefinition 17 + BlueskyPostCardDefinition, 18 + LivestreamCardDefitition, 19 + EmbedCardDefinition 16 20 ] as const; 17 21 18 22 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+6 -7
src/lib/cards/types.ts
··· 9 9 10 10 export type SettingsModalComponentProps = { 11 11 item: Item; 12 - onSave: (item: Item) => void; 13 - onCancel: () => void; 12 + onsave: (item: Item) => void; 13 + oncancel: () => void; 14 14 }; 15 15 16 16 export type SidebarComponentProps = { ··· 22 22 }; 23 23 24 24 export type CardDefinition = { 25 + type: string; 25 26 contentComponent: Component<ContentComponentProps>; 26 27 editingContentComponent?: Component<ContentComponentProps>; 27 28 28 29 createNew?: (item: Item) => void; 30 + 29 31 creationModalComponent?: Component<CreationModalComponentProps>; 30 - settingsModalComponent?: Component<{ 31 - item: Item; 32 - onSave: (item: Item) => void; 33 - onCancel: () => void; 34 - }>; 32 + 33 + settingsModalComponent?: Component<SettingsModalComponentProps>; 35 34 36 35 upload?: (item: Item) => Promise<Item>; 37 36
+4 -2
src/lib/oauth/atproto.ts
··· 38 38 export async function listRecords({ 39 39 did, 40 40 collection, 41 - cursor 41 + cursor, 42 + limit = 100 42 43 }: { 43 44 did: string; 44 45 collection: string; 45 46 cursor?: string; 47 + limit?: number; 46 48 }) { 47 49 const pds = await getPDS(did); 48 50 ··· 51 53 const room = await agent.com.atproto.repo.listRecords({ 52 54 repo: did, 53 55 collection, 54 - limit: 100, 56 + limit, 55 57 cursor 56 58 }); 57 59