initial scaffold: vue 3 + vite 8 + shadcn-vue + tailwind 4
Wires pinia, vue-router, vue-i18n, vee-validate/zod, shadcn-vue (reka-ui), tailwind 4 via @tailwindcss/vite. Sample HomeView proves i18n + pinia + tailwind. Optional nostr/lnbits feature folders ship as documentation only — deps install on opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
0054f3ab80
26 changed files with 3347 additions and 0 deletions
7
src/App.vue
Normal file
7
src/App.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
67
src/features/lnbits/README.md
Normal file
67
src/features/lnbits/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# LNbits payments feature (opt-in)
|
||||
|
||||
The boilerplate ships **no payment code by default**. Enable when the
|
||||
site needs to accept Lightning payments via LNbits.
|
||||
|
||||
## Enable
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```sh
|
||||
pnpm add light-bolt11-decoder qrcode
|
||||
pnpm add -D @types/qrcode
|
||||
```
|
||||
|
||||
2. Create `src/features/lnbits/store.ts`:
|
||||
|
||||
```ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useLnbitsStore = defineStore('lnbits', () => {
|
||||
const apiBase = ref<string>(import.meta.env.VITE_LNBITS_URL ?? '')
|
||||
const invoiceKey = ref<string>(import.meta.env.VITE_LNBITS_INVOICE_KEY ?? '')
|
||||
|
||||
async function createInvoice(amountSats: number, memo: string) {
|
||||
const res = await fetch(`${apiBase.value}/api/v1/payments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Api-Key': invoiceKey.value,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ out: false, amount: amountSats, memo }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`lnbits invoice creation failed: ${res.status}`)
|
||||
return res.json() as Promise<{
|
||||
payment_hash: string
|
||||
payment_request: string
|
||||
}>
|
||||
}
|
||||
|
||||
return { apiBase, invoiceKey, createInvoice }
|
||||
})
|
||||
```
|
||||
|
||||
3. Configure via `.env.local`:
|
||||
|
||||
```
|
||||
VITE_LNBITS_URL=https://demo.lnbits.com
|
||||
VITE_LNBITS_INVOICE_KEY=<your-invoice-key>
|
||||
```
|
||||
|
||||
4. Use:
|
||||
|
||||
```ts
|
||||
import { useLnbitsStore } from '@/features/lnbits/store'
|
||||
const lnbits = useLnbitsStore()
|
||||
const inv = await lnbits.createInvoice(100, 'Tip')
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The LNbits **invoice key** (read: "incoming payments only") is safe to
|
||||
embed in a frontend build. Never embed the **admin key**.
|
||||
- For polling/streaming payment status, hit `/api/v1/payments/<hash>`
|
||||
(or use the websocket endpoint).
|
||||
- Pair with the `nostr` feature for payment notifications to the site
|
||||
owner's npub.
|
||||
60
src/features/nostr/README.md
Normal file
60
src/features/nostr/README.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Nostr feature (opt-in)
|
||||
|
||||
The boilerplate ships **no nostr code by default** — bundle stays small.
|
||||
Enable per-site when you need it.
|
||||
|
||||
## Enable
|
||||
|
||||
1. Install dependency:
|
||||
|
||||
```sh
|
||||
pnpm add nostr-tools
|
||||
```
|
||||
|
||||
2. Create `src/features/nostr/store.ts`:
|
||||
|
||||
```ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { SimplePool, type Event } from 'nostr-tools'
|
||||
|
||||
const DEFAULT_RELAYS = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
]
|
||||
|
||||
export const useNostrStore = defineStore('nostr', () => {
|
||||
const pool = new SimplePool()
|
||||
const relays = ref<string[]>(DEFAULT_RELAYS)
|
||||
|
||||
async function publish(event: Event) {
|
||||
return Promise.allSettled(pool.publish(relays.value, event))
|
||||
}
|
||||
|
||||
return { relays, publish }
|
||||
})
|
||||
```
|
||||
|
||||
3. Use:
|
||||
|
||||
```ts
|
||||
import { useNostrStore } from '@/features/nostr/store'
|
||||
const nostr = useNostrStore()
|
||||
```
|
||||
|
||||
## Common patterns
|
||||
|
||||
- **Contact form → owner's npub**: build an event (NIP-04 DM or kind:1
|
||||
with a `p` tag), sign with the visitor's NIP-07 extension (or an
|
||||
ephemeral key for anonymous submissions), publish to the owner's
|
||||
preferred relays.
|
||||
- **Notifications without leaking infra**: pair with the `lnbits` feature
|
||||
so payment events trigger nostr DMs to the owner without exposing the
|
||||
LNbits admin URL.
|
||||
- **NIP-46 remote signing**: see [`fiatjaf/nak`](https://github.com/fiatjaf/nak)
|
||||
and [`kind-0/nsecbunkerd`](https://github.com/kind-0/nsecbunkerd) for
|
||||
bunker-backed signing flows.
|
||||
|
||||
See [nbd-wtf/nostr-tools](https://github.com/nbd-wtf/nostr-tools) for
|
||||
primitives (encryption, signing, relay pools).
|
||||
10
src/i18n/index.ts
Normal file
10
src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { createI18n } from 'vue-i18n'
|
||||
import en from './locales/en.json'
|
||||
import es from './locales/es.json'
|
||||
|
||||
export default createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages: { en, es },
|
||||
})
|
||||
11
src/i18n/locales/en.json
Normal file
11
src/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"app": {
|
||||
"title": "Boilerplate Website"
|
||||
},
|
||||
"home": {
|
||||
"heading": "Welcome",
|
||||
"intro": "Edit src/views/HomeView.vue to begin.",
|
||||
"counter": "Count: {n}",
|
||||
"increment": "Increment"
|
||||
}
|
||||
}
|
||||
11
src/i18n/locales/es.json
Normal file
11
src/i18n/locales/es.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"app": {
|
||||
"title": "Plantilla de Sitio Web"
|
||||
},
|
||||
"home": {
|
||||
"heading": "Bienvenido",
|
||||
"intro": "Edita src/views/HomeView.vue para empezar.",
|
||||
"counter": "Cuenta: {n}",
|
||||
"increment": "Incrementar"
|
||||
}
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
16
src/main.ts
Normal file
16
src/main.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
app.mount('#app')
|
||||
16
src/router/index.ts
Normal file
16
src/router/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
11
src/stores/counter.ts
Normal file
11
src/stores/counter.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const double = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
return { count, double, increment }
|
||||
})
|
||||
83
src/style.css
Normal file
83
src/style.css
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(0 0% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(0 0% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(0 0% 3.9%);
|
||||
--primary: hsl(0 0% 9%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(0 0% 96.1%);
|
||||
--secondary-foreground: hsl(0 0% 9%);
|
||||
--muted: hsl(0 0% 96.1%);
|
||||
--muted-foreground: hsl(0 0% 45.1%);
|
||||
--accent: hsl(0 0% 96.1%);
|
||||
--accent-foreground: hsl(0 0% 9%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(0 0% 89.8%);
|
||||
--input: hsl(0 0% 89.8%);
|
||||
--ring: hsl(0 0% 3.9%);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(0 0% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--card: hsl(0 0% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(0 0% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(0 0% 9%);
|
||||
--secondary: hsl(0 0% 14.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(0 0% 14.9%);
|
||||
--muted-foreground: hsl(0 0% 63.9%);
|
||||
--accent: hsl(0 0% 14.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(0 0% 14.9%);
|
||||
--input: hsl(0 0% 14.9%);
|
||||
--ring: hsl(0 0% 83.1%);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
32
src/views/HomeView.vue
Normal file
32
src/views/HomeView.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useCounterStore } from '@/stores/counter'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const counter = useCounterStore()
|
||||
|
||||
function toggleLocale() {
|
||||
locale.value = locale.value === 'en' ? 'es' : 'en'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mx-auto max-w-2xl space-y-6 p-8">
|
||||
<h1 class="text-3xl font-bold">{{ t('home.heading') }}</h1>
|
||||
<p class="text-muted-foreground">{{ t('home.intro') }}</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p>{{ t('home.counter', { n: counter.count }) }}</p>
|
||||
<button
|
||||
class="rounded-md bg-primary px-4 py-2 text-primary-foreground hover:opacity-90"
|
||||
@click="counter.increment"
|
||||
>
|
||||
{{ t('home.increment') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="text-sm underline" @click="toggleLocale">
|
||||
Switch to {{ locale === 'en' ? 'Español' : 'English' }}
|
||||
</button>
|
||||
</main>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue