your personal website on atproto - mirror blento.app

Merge pull request #31 from jycouet/feat/links-qrcode

Sharing links `qrcode`

authored by Florian and committed by GitHub 1b9e459c 0466dbed

+331 -23
+1
package.json
··· 73 73 "mapbox-gl": "^3.18.1", 74 74 "marked": "^17.0.1", 75 75 "plyr": "^3.8.4", 76 + "qr-code-styling": "^1.8.6", 76 77 "simple-icons": "^16.6.0", 77 78 "svelte-sonner": "^1.0.7", 78 79 "tailwind-merge": "^3.4.0",
+16
pnpm-lock.yaml
··· 110 110 plyr: 111 111 specifier: ^3.8.4 112 112 version: 3.8.4 113 + qr-code-styling: 114 + specifier: ^1.8.6 115 + version: 1.9.2 113 116 simple-icons: 114 117 specifier: ^16.6.0 115 118 version: 16.6.0 ··· 2484 2487 punycode@2.3.1: 2485 2488 resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} 2486 2489 engines: {node: '>=6'} 2490 + 2491 + qr-code-styling@1.9.2: 2492 + resolution: {integrity: sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==} 2493 + engines: {node: '>=18.18.0'} 2494 + 2495 + qrcode-generator@1.5.2: 2496 + resolution: {integrity: sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==} 2487 2497 2488 2498 quickselect@3.0.0: 2489 2499 resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} ··· 5047 5057 punycode.js@2.3.1: {} 5048 5058 5049 5059 punycode@2.3.1: {} 5060 + 5061 + qr-code-styling@1.9.2: 5062 + dependencies: 5063 + qrcode-generator: 1.5.2 5064 + 5065 + qrcode-generator@1.5.2: {} 5050 5066 5051 5067 quickselect@3.0.0: {} 5052 5068
+16 -3
src/lib/cards/BigSocialCard/BigSocialCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { platformsData } from '.'; 3 3 import type { ContentComponentProps } from '../types'; 4 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 4 5 5 6 let { item, isEditing }: ContentComponentProps = $props(); 6 7 7 8 const platform = $derived(item.cardData.platform as string); 9 + const platformData = $derived(platformsData[platform]); 8 10 </script> 9 11 10 12 <div ··· 14 16 <div 15 17 class="flex aspect-square max-h-full max-w-full items-center justify-center [&_svg]:size-full [&_svg]:max-w-60 [&_svg]:fill-white" 16 18 > 17 - {@html platformsData[platform].svg} 19 + {@html platformData?.svg} 18 20 </div> 19 21 </div> 20 22 21 23 {#if !isEditing} 22 - <a href={item.cardData.href} target="_blank" rel="noopener noreferrer"> 24 + <a 25 + href={item.cardData.href} 26 + target="_blank" 27 + rel="noopener noreferrer" 28 + use:qrOverlay={{ 29 + context: { 30 + title: platformData?.title, 31 + icon: platformData?.svg, 32 + iconColor: platformData?.hex 33 + } 34 + }} 35 + > 23 36 <div class="absolute inset-0 z-50"></div> 24 - <span class="sr-only">open {platformsData[platform].title}</span> 37 + <span class="sr-only">open {platformData?.title}</span> 25 38 </a> 26 39 {/if}
+12 -3
src/lib/cards/BlueskyProfileCard/BlueskyProfileCard.svelte
··· 1 1 <script lang="ts"> 2 - import type { Item } from '$lib/types'; 2 + import type { ContentComponentProps } from '../types'; 3 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 3 4 4 - let { item }: { item: Item } = $props(); 5 + let { item, isEditing }: ContentComponentProps = $props(); 6 + 7 + const profileUrl = $derived(`https://bsky.app/profile/${item.cardData.handle}`); 5 8 </script> 6 9 7 10 <a 8 11 target="_blank" 9 - href="/{item.cardData.handle}" 12 + href={profileUrl} 10 13 class="flex h-full w-full flex-col items-center justify-center gap-2 rounded-xl p-2 transition-colors duration-150" 14 + use:qrOverlay={{ 15 + disabled: isEditing, 16 + context: { 17 + title: item.cardData.displayName || item.cardData.handle 18 + } 19 + }} 11 20 > 12 21 <img 13 22 src={item.cardData.avatar}
+6
src/lib/cards/EventCard/EventCard.svelte
··· 7 7 import type { EventData } from '.'; 8 8 import { parseUri } from '$lib/atproto'; 9 9 import { browser } from '$app/environment'; 10 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 10 11 11 12 let { item }: ContentComponentProps = $props(); 12 13 ··· 268 269 class="absolute inset-0 h-full w-full" 269 270 target="_blank" 270 271 rel="noopener noreferrer" 272 + use:qrOverlay={{ 273 + context: { 274 + title: eventData?.name ?? '' 275 + } 276 + }} 271 277 > 272 278 <span class="sr-only">View event on smokesignal.events</span> 273 279 </a>
+14 -4
src/lib/cards/GitHubProfileCard/GitHubProfileCard.svelte
··· 7 7 import GithubContributionsGraph from './GithubContributionsGraph.svelte'; 8 8 import { Button } from '@foxui/core'; 9 9 import { browser } from '$app/environment'; 10 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 11 + 12 + let { item, isEditing }: ContentComponentProps = $props(); 10 13 11 - let { item }: ContentComponentProps = $props(); 14 + const githubUrl = $derived(`https://github.com/${item.cardData.user}`); 12 15 13 16 const data = getAdditionalUserData(); 14 17 ··· 75 78 </div> 76 79 </div> 77 80 78 - {#if item.cardData.href} 81 + {#if (item.cardData.href || item.cardData.user) && !isEditing} 79 82 <a 80 - href={item.cardData.href} 83 + href={item.cardData.href || githubUrl} 81 84 class="absolute inset-0 h-full w-full" 82 85 target="_blank" 83 86 rel="noopener noreferrer" 87 + use:qrOverlay={{ 88 + context: { 89 + title: item.cardData.user, 90 + icon: siGithub.svg, 91 + iconColor: siGithub.hex 92 + } 93 + }} 84 94 > 85 - <span class="sr-only"> Show on github </span> 95 + <span class="sr-only">Show on github</span> 86 96 </a> 87 97 {/if}
+2
src/lib/cards/ImageCard/ImageCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { getDidContext } from '$lib/website/context'; 3 3 import type { ContentComponentProps } from '../types'; 4 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 4 5 import { getImage } from '$lib/helper'; 5 6 6 7 let { item = $bindable(), isEditing }: ContentComponentProps = $props(); ··· 24 25 class="absolute inset-0 z-50 h-full w-full" 25 26 target="_blank" 26 27 rel="noopener noreferrer" 28 + use:qrOverlay={{ context: { title: item.cardData.hrefText ?? 'Learn more' } }} 27 29 > 28 30 <span class="sr-only"> 29 31 {item.cardData.hrefText ?? 'Learn more'}
+9 -2
src/lib/cards/LinkCard/LinkCard.svelte
··· 3 3 import { getImage } from '$lib/helper'; 4 4 import { getDidContext, getIsMobile } from '$lib/website/context'; 5 5 import type { ContentComponentProps } from '../types'; 6 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 6 7 7 - let { item }: ContentComponentProps = $props(); 8 + let { item, isEditing }: ContentComponentProps = $props(); 8 9 9 10 let isMobile = getIsMobile(); 10 11 ··· 65 66 alt="" 66 67 /> 67 68 {/if} 68 - {#if item.cardData.href} 69 + {#if item.cardData.href && !isEditing} 69 70 <a 70 71 href={item.cardData.href} 71 72 class="absolute inset-0 h-full w-full" 72 73 target="_blank" 73 74 rel="noopener noreferrer" 75 + use:qrOverlay={{ 76 + context: { 77 + title: item.cardData.title, 78 + favicon: item.cardData.favicon 79 + } 80 + }} 74 81 > 75 82 <span class="sr-only"> 76 83 {item.cardData.hrefText ?? 'Learn more'}
+8 -2
src/lib/cards/MapCard/MapCard.svelte
··· 1 1 <script lang="ts"> 2 2 import type { ContentComponentProps } from '../types'; 3 3 import Map from './Map.svelte'; 4 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 4 5 5 6 let { item = $bindable(), isEditing }: ContentComponentProps = $props(); 7 + 8 + const mapsUrl = $derived( 9 + 'https://maps.google.com/maps?q=' + 10 + encodeURIComponent(item.cardData.lat + ',' + item.cardData.lon) 11 + ); 6 12 </script> 7 13 8 14 <Map bind:item /> ··· 11 17 <a 12 18 target="_blank" 13 19 rel="noopener noreferrer" 14 - href={'http://maps.google.com/maps?q=' + 15 - encodeURIComponent(item.cardData.lat + ',' + item.cardData.lon)} 20 + href={mapsUrl} 21 + use:qrOverlay={{ context: { title: 'Google Maps' } }} 16 22 > 17 23 <div class="absolute inset-0 z-100"></div> 18 24 <span class="sr-only">open map</span>
+83
src/lib/components/qr/QRCodeDisplay.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + 4 + let { 5 + url, 6 + icon, 7 + iconColor, 8 + class: className = '' 9 + }: { 10 + url: string; 11 + icon?: string; 12 + iconColor?: string; 13 + class?: string; 14 + } = $props(); 15 + 16 + let container: HTMLDivElement | undefined = $state(); 17 + 18 + // Convert SVG string to data URI for use as QR center image 19 + function svgToDataUri(svg: string, color: string): string { 20 + // Add fill color to SVG - insert fill attribute on the svg tag 21 + let coloredSvg = svg; 22 + if (!svg.includes('fill=')) { 23 + // No fill attribute, add it to the svg tag 24 + coloredSvg = svg.replace('<svg', `<svg fill="${color}"`); 25 + } else { 26 + // Replace existing fill attributes 27 + coloredSvg = svg.replace(/fill="[^"]*"/g, `fill="${color}"`); 28 + } 29 + const encoded = encodeURIComponent(coloredSvg); 30 + return `data:image/svg+xml,${encoded}`; 31 + } 32 + 33 + onMount(async () => { 34 + if (!container) return; 35 + 36 + // Use iconColor or default accent, ensure # prefix 37 + const rawColor = iconColor || 'f6339a'; 38 + const dotColor = rawColor.startsWith('#') ? rawColor : `#${rawColor}`; 39 + 40 + const module = await import('qr-code-styling'); 41 + const QRCodeStyling = module.default; 42 + 43 + // Get container size for responsive QR 44 + const rect = container.getBoundingClientRect(); 45 + const size = Math.min(rect.width, rect.height) || 280; 46 + 47 + const options: ConstructorParameters<typeof QRCodeStyling>[0] = { 48 + width: size, 49 + height: size, 50 + data: url, 51 + dotsOptions: { 52 + color: dotColor, 53 + type: 'rounded' 54 + }, 55 + backgroundOptions: { 56 + color: '#FFF' 57 + }, 58 + cornersSquareOptions: { 59 + type: 'extra-rounded', 60 + color: dotColor 61 + }, 62 + cornersDotOptions: { 63 + type: 'dot', 64 + color: dotColor 65 + }, 66 + margin: 10 67 + }; 68 + 69 + // Add icon as center image if provided (as SVG string) 70 + if (icon) { 71 + options.image = svgToDataUri(icon, dotColor); 72 + options.imageOptions = { 73 + margin: 10, 74 + imageSize: 0.5 75 + }; 76 + } 77 + 78 + const qrCode = new QRCodeStyling(options); 79 + qrCode.append(container); 80 + }); 81 + </script> 82 + 83 + <div bind:this={container} class="flex items-center justify-center {className}"></div>
+39
src/lib/components/qr/QRCodeModal.svelte
··· 1 + <script lang="ts"> 2 + import { Modal } from '@foxui/core'; 3 + import QRCodeDisplay from './QRCodeDisplay.svelte'; 4 + 5 + export type QRContext = { 6 + title?: string; 7 + icon?: string; 8 + iconColor?: string; 9 + }; 10 + 11 + let { 12 + open = $bindable(false), 13 + href, 14 + context = {} 15 + }: { 16 + open: boolean; 17 + href: string; 18 + context?: QRContext; 19 + } = $props(); 20 + </script> 21 + 22 + <Modal bind:open closeButton={true} class="max-w-[90vw]! sm:max-w-sm! md:max-w-md!"> 23 + <div class="flex flex-col items-center justify-center gap-4 p-4"> 24 + {#if context.title} 25 + <div class="text-base-900 dark:text-base-100 text-center text-xl font-semibold"> 26 + {context.title} 27 + </div> 28 + {/if} 29 + 30 + <div class="flex items-center justify-center overflow-hidden rounded-2xl"> 31 + <QRCodeDisplay 32 + url={href} 33 + icon={context.icon} 34 + iconColor={context.iconColor} 35 + class="size-[min(70vw,320px)] sm:size-72 md:size-80" 36 + /> 37 + </div> 38 + </div> 39 + </Modal>
+25
src/lib/components/qr/QRModalProvider.svelte
··· 1 + <script lang="ts"> 2 + import { onMount, onDestroy } from 'svelte'; 3 + import QRCodeModal, { type QRContext } from './QRCodeModal.svelte'; 4 + import { registerQRModal, unregisterQRModal } from './qrOverlay.svelte'; 5 + 6 + let open = $state(false); 7 + let href = $state(''); 8 + let context = $state<QRContext>({}); 9 + 10 + function showModal(newHref: string, newContext: QRContext) { 11 + href = newHref; 12 + context = newContext; 13 + open = true; 14 + } 15 + 16 + onMount(() => { 17 + registerQRModal(showModal); 18 + }); 19 + 20 + onDestroy(() => { 21 + unregisterQRModal(); 22 + }); 23 + </script> 24 + 25 + <QRCodeModal bind:open {href} {context} />
+76
src/lib/components/qr/qrOverlay.svelte.ts
··· 1 + import type { QRContext } from './QRCodeModal.svelte'; 2 + 3 + // Global state for QR modal 4 + let openModal: ((href: string, context: QRContext) => void) | null = null; 5 + 6 + export function registerQRModal(fn: (href: string, context: QRContext) => void) { 7 + openModal = fn; 8 + } 9 + 10 + export function unregisterQRModal() { 11 + openModal = null; 12 + } 13 + 14 + export function qrOverlay( 15 + node: HTMLElement, 16 + params: { href?: string; context?: QRContext; disabled?: boolean } = {} 17 + ) { 18 + const LONG_PRESS_DURATION = 500; 19 + let longPressTimer: ReturnType<typeof setTimeout> | null = null; 20 + let isLongPress = false; 21 + 22 + function getHref() { 23 + return params.href || (node as HTMLAnchorElement).href || ''; 24 + } 25 + 26 + function startLongPress() { 27 + if (params.disabled) return; 28 + isLongPress = false; 29 + longPressTimer = setTimeout(() => { 30 + isLongPress = true; 31 + openModal?.(getHref(), params.context ?? {}); 32 + }, LONG_PRESS_DURATION); 33 + } 34 + 35 + function cancelLongPress() { 36 + if (longPressTimer) { 37 + clearTimeout(longPressTimer); 38 + longPressTimer = null; 39 + } 40 + } 41 + 42 + function handleClick(e: MouseEvent) { 43 + if (isLongPress) { 44 + e.preventDefault(); 45 + isLongPress = false; 46 + } 47 + } 48 + 49 + function handleContextMenu(e: MouseEvent) { 50 + if (params.disabled) return; 51 + e.preventDefault(); 52 + openModal?.(getHref(), params.context ?? {}); 53 + } 54 + 55 + node.addEventListener('pointerdown', startLongPress); 56 + node.addEventListener('pointerup', cancelLongPress); 57 + node.addEventListener('pointercancel', cancelLongPress); 58 + node.addEventListener('pointerleave', cancelLongPress); 59 + node.addEventListener('click', handleClick); 60 + node.addEventListener('contextmenu', handleContextMenu); 61 + 62 + return { 63 + update(newParams: { href?: string; context?: QRContext; disabled?: boolean }) { 64 + params = newParams; 65 + }, 66 + destroy() { 67 + node.removeEventListener('pointerdown', startLongPress); 68 + node.removeEventListener('pointerup', cancelLongPress); 69 + node.removeEventListener('pointercancel', cancelLongPress); 70 + node.removeEventListener('pointerleave', cancelLongPress); 71 + node.removeEventListener('click', handleClick); 72 + node.removeEventListener('contextmenu', handleContextMenu); 73 + cancelLongPress(); 74 + } 75 + }; 76 + }
+22 -9
src/lib/website/Profile.svelte
··· 8 8 import { getDescription, getName } from '$lib/helper'; 9 9 import { page } from '$app/state'; 10 10 import type { ActorIdentifier } from '@atcute/lexicons'; 11 + import { qrOverlay } from '$lib/components/qr/qrOverlay.svelte'; 11 12 12 13 let { 13 14 data, ··· 20 21 const renderer = new marked.Renderer(); 21 22 renderer.link = ({ href, title, text }) => 22 23 `<a target="_blank" href="${href}" title="${title}">${text}</a>`; 24 + 25 + const profileUrl = $derived(`${page.url.origin}/${data.handle}`); 23 26 </script> 24 27 25 28 <!-- lg:fixed lg:h-screen lg:w-1/4 lg:max-w-none lg:px-12 lg:pt-24 xl:w-1/3 --> ··· 27 30 class="mx-auto flex max-w-lg flex-col justify-between px-8 @5xl/wrapper:fixed @5xl/wrapper:h-screen @5xl/wrapper:w-1/4 @5xl/wrapper:max-w-none @5xl/wrapper:px-12" 28 31 > 29 32 <div class="flex flex-col gap-4 pt-16 pb-8 @5xl/wrapper:h-screen @5xl/wrapper:pt-24"> 30 - {#if data.profile.avatar} 31 - <img 32 - class="border-base-400 dark:border-base-800 size-32 rounded-full border @5xl/wrapper:size-44" 33 - src={data.profile.avatar} 34 - alt="" 35 - /> 36 - {:else} 37 - <div class="bg-base-300 dark:bg-base-700 size-32 rounded-full @5xl/wrapper:size-44"></div> 38 - {/if} 33 + <a 34 + href={profileUrl} 35 + class="w-fit" 36 + use:qrOverlay={{ 37 + context: { 38 + title: getName(data) + "'s blento" 39 + } 40 + }} 41 + > 42 + {#if data.profile.avatar} 43 + <img 44 + class="border-base-400 dark:border-base-800 size-32 rounded-full border @5xl/wrapper:size-44" 45 + src={data.profile.avatar} 46 + alt="" 47 + /> 48 + {:else} 49 + <div class="bg-base-300 dark:bg-base-700 size-32 rounded-full @5xl/wrapper:size-44"></div> 50 + {/if} 51 + </a> 39 52 40 53 <div class="text-4xl font-bold wrap-anywhere"> 41 54 {getName(data)}
+2
src/lib/website/Website.svelte
··· 9 9 import Context from './Context.svelte'; 10 10 import Head from './Head.svelte'; 11 11 import type { Did, Handle } from '@atcute/lexicons'; 12 + import QRModalProvider from '$lib/components/qr/QRModalProvider.svelte'; 12 13 13 14 let { data }: { data: WebsiteData } = $props(); 14 15 ··· 38 39 /> 39 40 40 41 <Context {data}> 42 + <QRModalProvider /> 41 43 <div class="@container/wrapper relative w-full"> 42 44 {#if !getHideProfileSection(data)} 43 45 <Profile {data} showEditButton={true} />