This commit is contained in:
2025-12-02 19:56:36 +01:00
commit 304dbe0a19
13 changed files with 2524 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
# Wrangler/Cloudflare
.wrangler
wrangler.toml.local

59
README.md Normal file
View File

@@ -0,0 +1,59 @@
# ssg - Secret Santa Generator
A simple, no-fuzz Secret Santa participant pairing tool. Generate random pairings and share unique links with each participant to reveal their assignment.
## Features
- **Simple UI**: Clean, monochrome design with no rounded corners
- **Easy Setup**: Add participant names and generate pairings
- **Shareable Links**: Each participant gets a unique link to reveal their Secret Santa assignment
- **No Self-Pairing**: Ensures no one is assigned to themselves
- **Built with**: Nuxt 4, Vue 3, Nuxt UI, Tailwind CSS
## Getting Started
### Setup
Install dependencies:
```bash
bun install
```
### Development
Start the development server:
```bash
bun run dev
```
The app will be available at `http://localhost:3000`
### Build
Build for production:
```bash
bun run build
```
Preview production build:
```bash
bun run preview
```
## How to Use
1. **Add Participants**: Enter the names of everyone participating in Secret Santa
2. **Generate Pairings**: Click "Generate Pairings" to create random assignments
3. **Share Links**: Copy each participant's unique link
4. **Send Links**: Share the link with each participant via email, message, etc.
5. **Reveal**: Participants click their link and reveal their assignment
## Design
- **Color Scheme**: Monochrome (zinc/black/white)
- **Radius**: 0 (no rounded corners)
- **UI Framework**: Nuxt UI

6
app/app.vue Normal file
View File

@@ -0,0 +1,6 @@
<template>
<div class="min-h-screen bg-zinc-950 text-zinc-50">
<NuxtRouteAnnouncer />
<NuxtPage />
</div>
</template>

1
app/assets/css/main.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,70 @@
export interface Pairing {
giver: string
receiver: string
}
export function useSecretSanta() {
/**
* Shuffle array using Fisher-Yates algorithm
*/
function shuffle<T>(array: T[]): T[] {
const arr = [...array]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
/**
* Generate valid Secret Santa pairings
* Ensures no one is paired with themselves
*/
function generatePairings(names: string[]): Pairing[] {
if (names.length < 2) {
throw new Error('Need at least 2 participants')
}
let shuffled: string[]
let attempts = 0
const maxAttempts = 100
do {
shuffled = shuffle(names)
attempts++
if (attempts > maxAttempts) {
throw new Error('Failed to generate valid pairings after multiple attempts')
}
} while (shuffled.some((name, i) => name === names[i]))
return names.map((giver, i) => ({
giver,
receiver: shuffled[i],
}))
}
/**
* Encode pairing data to base64 for URL
*/
function encodePairing(pairing: Pairing): string {
return btoa(JSON.stringify(pairing))
}
/**
* Decode pairing data from base64
*/
function decodePairing(encoded: string): Pairing {
try {
return JSON.parse(atob(encoded))
}
catch (error) {
throw new Error('Invalid pairing data')
}
}
return {
generatePairings,
encodePairing,
decodePairing,
}
}

189
app/pages/index.vue Normal file
View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
const participants = ref<string[]>(['', ''])
const pairings = ref<any[]>([])
const generated = ref(false)
const error = ref('')
const copied = ref<number | null>(null)
const { generatePairings, encodePairing } = useSecretSanta()
// Auto-expand participant list when last field is filled
watch(participants, (newParticipants) => {
const lastIndex = newParticipants.length - 1
if (lastIndex >= 0 && newParticipants[lastIndex].trim().length > 0) {
if (newParticipants.length < 10) { // Limit to 10 participants
participants.value.push('')
}
}
}, { deep: true })
function removeParticipant(index: number) {
participants.value.splice(index, 1)
}
function generate() {
error.value = ''
const names = participants.value
.map(n => n.trim())
.filter(n => n.length > 0)
if (names.length < 2) {
error.value = 'Add at least 2 participants'
return
}
try {
const pairs = generatePairings(names)
pairings.value = pairs.map(p => ({
giver: p.giver,
receiver: p.receiver,
encoded: encodePairing(p),
}))
generated.value = true
}
catch (err: any) {
error.value = err.message
}
}
function reset() {
participants.value = ['', '']
pairings.value = []
generated.value = false
error.value = ''
copied.value = null
}
function getParticipantLink(encoded: string) {
if (process.client) {
return `${window.location.origin}/reveal/${encoded}`
}
return `/reveal/${encoded}`
}
async function copyToClipboard(text: string, index: number) {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
copied.value = index
setTimeout(() => {
copied.value = null
}, 2000)
} else {
// Fallback for environments without clipboard API
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
copied.value = index
setTimeout(() => {
copied.value = null
}, 2000)
}
}
catch (err) {
error.value = 'Failed to copy to clipboard'
}
}
</script>
<template>
<div class="min-h-screen bg-gradient-to-br from-zinc-950 via-zinc-900 to-zinc-950 text-zinc-50 p-6 md:p-12">
<div class="max-w-2xl mx-auto">
<!-- Header -->
<div class="mb-12">
<h1 class="text-5xl font-bold tracking-tight mb-2">ssg</h1>
<p class="text-zinc-400 text-base">secret santa generator</p>
</div>
<!-- Generator Form -->
<div v-if="!generated" class="space-y-8">
<div class="space-y-4">
<label class="block text-sm font-medium text-zinc-300">Participants</label>
<div class="space-y-3">
<div v-for="(participant, index) in participants" :key="index" class="flex gap-3">
<input
v-model="participants[index]"
type="text"
placeholder="Enter name"
class="flex-1 bg-zinc-800/50 border border-zinc-700 px-4 py-2.5 text-sm text-zinc-50 placeholder-zinc-500 focus:outline-none focus:border-zinc-600 focus:ring-1 focus:ring-zinc-600 transition-colors rounded"
/>
<button
v-if="participants.length > 2"
@click="removeParticipant(index)"
class="px-3 py-2.5 bg-zinc-800/50 border border-zinc-700 text-zinc-400 text-sm hover:bg-red-900/20 hover:border-red-700 transition-colors rounded"
title="Remove participant"
>
</button>
</div>
</div>
<p class="text-xs text-zinc-500">{{ participants.filter(p => p.trim()).length }} participant(s) added</p>
</div>
<div v-if="error" class="bg-red-900/20 border border-red-800 px-4 py-3 text-sm text-red-400 rounded">
{{ error }}
</div>
<button
@click="generate"
class="w-full bg-zinc-100 text-zinc-950 px-6 py-3 text-sm font-medium hover:bg-zinc-200 transition-colors rounded font-semibold"
>
Generate Pairings
</button>
</div>
<!-- Results -->
<div v-else class="space-y-8">
<div class="flex items-center justify-between mb-8">
<div>
<h2 class="text-2xl font-semibold mb-1">Pairings Generated</h2>
<p class="text-zinc-400 text-sm">{{ pairings.length }} participant(s)</p>
</div>
<button
@click="reset"
class="px-4 py-2.5 bg-zinc-800/50 border border-zinc-700 text-sm text-zinc-300 hover:bg-zinc-700 transition-colors rounded"
>
Reset
</button>
</div>
<div class="space-y-3">
<div v-for="(pairing, index) in pairings" :key="index" class="border border-zinc-700 bg-zinc-800/30 rounded overflow-hidden">
<div class="p-5 space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-zinc-300 uppercase tracking-wider mb-1 font-semibold">Link for {{ pairing.giver }}</p>
</div>
</div>
<div class="flex gap-2">
<input
:value="getParticipantLink(pairing.encoded)"
readonly
type="text"
class="flex-1 bg-zinc-700/50 border border-zinc-600 px-3 py-2 text-xs text-zinc-400 focus:outline-none rounded"
/>
<button
@click="copyToClipboard(getParticipantLink(pairing.encoded), index)"
:class="[
'px-3 py-2 text-xs font-medium rounded transition-colors',
copied === index
? 'bg-green-900/30 border border-green-700 text-green-400'
: 'bg-zinc-700/50 border border-zinc-600 text-zinc-300 hover:bg-zinc-600/50'
]"
>
{{ copied === index ? '✓ Copied' : 'Copy' }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const route = useRoute()
const revealed = ref(false)
const receiver = ref('')
const giver = ref('')
const error = ref('')
const { decodePairing } = useSecretSanta()
onMounted(() => {
try {
const code = route.params.code as string
if (!code) {
error.value = 'Invalid link'
return
}
const pairing = decodePairing(code)
giver.value = pairing.giver
receiver.value = pairing.receiver
}
catch (err: any) {
error.value = 'Invalid or corrupted link'
}
})
function reveal() {
revealed.value = true
}
</script>
<template>
<div class="min-h-screen bg-zinc-950 text-zinc-50 flex items-center justify-center p-4">
<div class="max-w-md w-full">
<div v-if="error" class="text-center space-y-4">
<div class="text-2xl font-bold text-red-400">Error</div>
<p class="text-zinc-400">{{ error }}</p>
<NuxtLink
to="/"
class="inline-block px-4 py-2 bg-zinc-900 border border-zinc-800 text-sm hover:bg-zinc-800 transition-colors"
>
Back to Generator
</NuxtLink>
</div>
<div v-else-if="!revealed" class="text-center space-y-8">
<div>
<h1 class="text-4xl font-bold tracking-tight mb-2">ssg</h1>
<p class="text-zinc-400 text-sm">secret santa generator</p>
</div>
<div class="space-y-4">
<p class="text-zinc-300">Hello <span class="font-medium text-zinc-50">{{ giver }}</span>!</p>
<p class="text-zinc-400 text-sm">Click the button below to find out who you're gifting to.</p>
</div>
<button
@click="reveal"
class="w-full bg-zinc-100 text-zinc-950 px-6 py-3 font-medium hover:bg-zinc-200 transition-colors text-lg"
>
Reveal
</button>
</div>
<div v-else class="text-center space-y-8">
<div>
<h1 class="text-4xl font-bold tracking-tight mb-2">ssg</h1>
<p class="text-zinc-400 text-sm">secret santa generator</p>
</div>
<div class="border border-zinc-800 bg-zinc-900 p-8 space-y-4">
<p class="text-zinc-500 text-sm uppercase tracking-wider">You are gifting to</p>
<p class="text-3xl font-bold">{{ receiver }}</p>
</div>
<p class="text-zinc-400 text-sm">🎄 Happy secret santa-ing! 🎄</p>
<NuxtLink
to="/"
class="block px-4 py-2 bg-zinc-900 border border-zinc-800 text-sm text-zinc-400 hover:bg-zinc-800 transition-colors"
>
Create New Secret Santa
</NuxtLink>
</div>
</div>
</div>
</template>

2006
bun.lock Normal file

File diff suppressed because it is too large Load Diff

21
nuxt.config.ts Normal file
View File

@@ -0,0 +1,21 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
colorMode: {
preference: 'dark',
classSuffix: '',
},
nitro: {
prerender: {
crawlLinks: true,
routes: ['/', '/robots.txt'],
ignore: ['/reveal'],
},
},
routeRules: {
'/**': { cache: { maxAge: 60 * 10 } },
},
})

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "ssg",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"deploy": "wrangler deploy"
},
"dependencies": {
"@nuxt/ui": "4.2.1",
"autoprefixer": "^10.4.22",
"nuxt": "^4.2.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "^5.6.3",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
},
"devDependencies": {
"wrangler": "^3.95.0"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

11
tailwind.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { Config } from 'tailwindcss'
export default {
content: [
'./app/components/**/*.{js,vue,ts}',
'./app/layouts/**/*.vue',
'./app/pages/**/*.vue',
'./app/plugins/**/*.{js,ts}',
'./app/app.vue',
],
} satisfies Config

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}