mirror of
https://github.com/SrIzan10/ssg.git
synced 2026-06-06 01:06:52 +00:00
init
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
59
README.md
Normal 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
6
app/app.vue
Normal 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
1
app/assets/css/main.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
70
app/composables/useSecretSanta.ts
Normal file
70
app/composables/useSecretSanta.ts
Normal 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
189
app/pages/index.vue
Normal 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>
|
||||
89
app/pages/reveal/[code].vue
Normal file
89
app/pages/reveal/[code].vue
Normal 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>
|
||||
21
nuxt.config.ts
Normal file
21
nuxt.config.ts
Normal 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
26
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
11
tailwind.config.ts
Normal file
11
tailwind.config.ts
Normal 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
18
tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user