Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vscode
39 changes: 39 additions & 0 deletions src/lib/assets/share-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 15 additions & 2 deletions src/lib/components/Carousel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
let {
index,
focusPips,
isRolling = false
isRolling = false,
sequence = $bindable()
}: {
index: number
focusPips: number | null
isRolling?: boolean
sequence: number[]
} = $props()

let emblaApi: EmblaCarouselType
Expand All @@ -26,8 +28,19 @@

// Attach event listener for slide change
// settle: Runs when the carousel has settled after scroll has been triggered.
// use event listener to update sequence if focusPips change in Carousel
emblaApi.on("settle", () => {
focusPips = emblaApi.selectedScrollSnap() + 1
// focusPips = emblaApi.selectedScrollSnap() + 1
const newFocusPips = emblaApi.selectedScrollSnap() + 1
// only update if pips actually changed
if (focusPips !== newFocusPips) {
focusPips = newFocusPips

if (sequence) {
sequence[index - 1] = newFocusPips
sequence = [...sequence]
}
}
})
}

Expand Down
86 changes: 86 additions & 0 deletions src/lib/components/ShareButton.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<script lang="ts">
import { locale } from "$lib/stores/locale"
import { fade } from "svelte/transition"

let { sequence } = $props<{ sequence: number[] }>()

// track if url has been copied to clipboard
let urlCopied: boolean = $state(false)

let sharingUrl = $derived(
window.location.origin + window.location.pathname + "#" + sequence.join("")
)

// function to copy url including sequence to clipboard
async function copyUrlToClipboard(event: MouseEvent) {
try {
const url = sharingUrl
await navigator.clipboard.writeText(url)
urlCopied = true

setTimeout(() => {
urlCopied = false
;(event.target as HTMLButtonElement).blur() // remove focus ring from the clicked button
}, 3000)
} catch (err) {
console.error("Failed to copy URL to clipboard:", err)
}
}

let showDialogue: boolean = $state(false)
function toggleDialogue(event: MouseEvent) {
showDialogue = !showDialogue
;(event.target as HTMLButtonElement).blur()
}
</script>

<!-- Share Button that opens dialogue for copying url with current sequence -->
<div class="fixed right-6 bottom-6 flex items-center space-x-2">
{#if showDialogue}
<div
transition:fade={{ duration: 200 }}
class="absolute right-20 bottom-0 rounded-lg border border-gray-300 bg-white p-3 shadow-lg"
>
<button
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 focus:outline-none"
onclick={toggleDialogue}
>
</button>
<p class="text-gray-700">
{$locale === "de"
? "Teile deinen gewürfelten Einakter:"
: "Share your randomised one-act play:"}
</p>
<div
class="mt-2 flex flex-col items-center space-y-2 space-x-2 sm:flex-row sm:space-y-0 sm:space-x-2"
>
<input type="text" class="rounded border bg-gray-100 p-1" value={sharingUrl} readonly />
<button
class="w-32 rounded bg-sky-800 p-1 text-white transition hover:bg-sky-700 focus:ring-2 focus:ring-sky-500 focus:ring-offset-2"
onclick={copyUrlToClipboard}
>
{urlCopied
? $locale === "de"
? "Link kopiert!"
: "Link copied!"
: $locale === "de"
? "Link kopieren"
: "Copy link"}
</button>
</div>
</div>
{/if}

<!-- Share Button -->
<button
onclick={toggleDialogue}
transition:fade={{ duration: 300 }}
class="flex h-18 w-18 cursor-pointer items-center justify-center rounded-full bg-sky-800 text-white shadow-lg transition-all hover:bg-sky-700 focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:outline-none"
aria-label={$locale === "de" ? "Dialog zum Teilen öffnen" : "Open share dialogue"}
title={$locale === "de" ? "Dialog zum Teilen öffnen" : "Open share dialogue"}
>
<!-- Share Icon -->
<img src="/src/lib/assets/share-icon.svg" alt="Share" class="h-5 w-5" />
</button>
</div>
97 changes: 61 additions & 36 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<script lang="ts">
import { replaceState } from "$app/navigation"
import { page } from "$app/state"
import Carousel from "$lib/components/Carousel.svelte"
import Dice3D from "$lib/components/Dice3D.svelte"
import ShareButton from "$lib/components/ShareButton.svelte"
import { generateRandomSequence } from "$lib/dice"
import { locale } from "$lib/stores/locale"
import emblaCarouselSvelte from "embla-carousel-svelte"
Expand All @@ -13,47 +15,73 @@
skipSnaps: true // Allow the carousel to skip scroll snaps if it's dragged vigorously
}

let sequence: number[] = $state([])
let isRolling = $state(false)

function toggleLanguage() {
$locale = $locale === "de" ? "en" : "de"
}

// On change to isRolling, set a new sequence
let sequence: number[] = $state([])
const sequenceLength = 200
const sequenceRegex = new RegExp(`^[1-6]{${sequenceLength}}$`)

// Mount gate
let mounted = $state(false)
onMount(() => {
mounted = true
})

// Deferred scroll flag
let wantsScroll = $state(false)

// Whenever the url hash changes, derive sequence
$effect(() => {
if (isRolling) {
const hash = page.url.hash.slice(1)

if (hash && sequenceRegex.test(hash)) {
sequence = hash.split("").map(Number)

// Reset hash
requestAnimationFrame(() => {
const url = new URL(page.url)
url.hash = ""
replaceState(url, page.state)
})

wantsScroll = true
} else if (sequence.length === 0) {
// Only set once on init
sequence = generateRandomSequence()
}
})

function initialiseSequence() {
// On load, check if there's a sequence in the URL hash
const hashString = page.url.hash.slice(1)
if (!hashString) return generateRandomSequence()

// Check if it’s a valid sequence
const sequenceLength = 200
const sequenceRegex = new RegExp(`^[1-6]{${sequenceLength}}$`)
if (!sequenceRegex.test(hashString)) {
return generateRandomSequence()
}
// Perform the scroll once everything exists
$effect(() => {
if (!mounted || !wantsScroll || !mainElement) return
// Wait for the next frame to ensure the DOM is ready
requestAnimationFrame(() => {
scrollToMain()
wantsScroll = false
})
})

// Valid, apply
const hashSequence = hashString.split("").map(Number)
// Reset hash
history.pushState("", document.title, window.location.pathname + window.location.search)
return hashSequence
// Reference to the main element
let mainElement: HTMLElement
function scrollToMain() {
mainElement?.scrollIntoView({ behavior: "smooth", block: "start" })
}

let isRolling = $state(false)
// On change to isRolling, set a new sequence
$effect(() => {
if (isRolling) {
sequence = generateRandomSequence()
}
})

// Track scroll position
let scrollY = $state(0)
let innerHeight = $state(0)
let showBackToTop = $derived(scrollY > innerHeight)

onMount(() => {
sequence = initialiseSequence()
})
let showShareButton = $derived(scrollY > innerHeight * 0.1)

// Add function to scroll back to top
function scrollToTop() {
Expand All @@ -62,16 +90,6 @@
behavior: "smooth"
})
}

// Reference to the main element
let mainElement: HTMLElement

// Add function to scroll to main content
function scrollToMain() {
mainElement?.scrollIntoView({
behavior: "smooth"
})
}
</script>

<svelte:window bind:scrollY bind:innerHeight />
Expand Down Expand Up @@ -160,6 +178,7 @@
<!-- Only apply the rolling effect to the first ten carousels -->
<Carousel
{index}
bind:sequence
focusPips={sequence ? sequence[index - 1] : null}
isRolling={index <= 10 ? isRolling : false}
/>
Expand All @@ -173,7 +192,7 @@
<button
onclick={scrollToTop}
transition:fade={{ duration: 300 }}
class="fixed right-6 bottom-6 flex h-18 w-18 cursor-pointer items-center justify-center rounded-full bg-sky-800 text-white shadow-lg transition-all hover:bg-sky-700 focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:outline-none"
class="fixed right-6 bottom-25 flex h-18 w-18 cursor-pointer items-center justify-center rounded-full bg-sky-800 text-white shadow-lg transition-all hover:bg-sky-700 focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:outline-none"
aria-label={$locale === "de" ? "Zurück nach oben" : "Back to top"}
title={$locale === "de" ? "Zurück nach oben" : "Back to top"}
>
Expand All @@ -188,6 +207,12 @@
</svg>
</button>
{/if}
<div>
{#if showShareButton}
<!-- Share Button Component -->
<ShareButton {sequence} />
{/if}
</div>

<style>
@keyframes pulse {
Expand Down
3 changes: 3 additions & 0 deletions svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const config = {
strict: true,
path: {
base: process.argv.includes("dev") ? "" : process.env.BASE_PATH
},
resolve: {
alias: {}
}
})
}
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"inlineSources": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
Expand Down