create-omg 0.4.41 → 0.4.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -6
- package/dist/index.mjs +71 -12
- package/dist/{template → templates/react-ts}/AGENTS.md +22 -0
- package/dist/templates/react-ts/_gitignore +24 -0
- package/dist/{template → templates/react-ts}/bun.lock +105 -58
- package/dist/templates/react-ts/components.json +25 -0
- package/dist/templates/react-ts/eslint.config.js +23 -0
- package/dist/templates/react-ts/functions/.gitkeep +0 -0
- package/dist/{template → templates/react-ts}/package.json +5 -0
- package/dist/templates/react-ts/public/favicon.svg +1 -0
- package/dist/templates/react-ts/public/icons/apple-touch-icon.png +0 -0
- package/dist/templates/react-ts/public/icons/pwa-192x192.png +0 -0
- package/dist/templates/react-ts/public/icons/pwa-512x512-maskable.png +0 -0
- package/dist/templates/react-ts/public/icons/pwa-512x512.png +0 -0
- package/dist/templates/react-ts/public/icons.svg +24 -0
- package/dist/templates/react-ts/schema.ts +5 -0
- package/dist/templates/react-ts/server/db.ts +103 -0
- package/dist/templates/react-ts/src/App.tsx +128 -0
- package/dist/templates/react-ts/src/components/ui/animated-number.tsx +102 -0
- package/dist/templates/react-ts/src/components/ui/animated-text.tsx +109 -0
- package/dist/templates/react-ts/src/components/ui/bottom-nav.tsx +131 -0
- package/dist/templates/react-ts/src/components/ui/button.tsx +84 -0
- package/dist/templates/react-ts/src/components/ui/card.tsx +70 -0
- package/dist/templates/react-ts/src/components/ui/otp-input.tsx +179 -0
- package/dist/templates/react-ts/src/components/ui/scroll-affordance.tsx +106 -0
- package/dist/templates/react-ts/src/components/ui/sound.ts +182 -0
- package/dist/templates/react-ts/src/components/ui/transitions.tsx +419 -0
- package/dist/templates/react-ts/src/components/ui/vibes-ui-styles.ts +388 -0
- package/dist/templates/react-ts/src/db.drizzle.ts +5 -0
- package/dist/templates/react-ts/src/index.css +168 -0
- package/dist/templates/react-ts/src/lib/utils.ts +6 -0
- package/dist/templates/react-ts/src/main.tsx +22 -0
- package/dist/templates/react-ts/src/routeTree.gen.ts +59 -0
- package/dist/templates/react-ts/src/routes/__root.tsx +7 -0
- package/dist/templates/react-ts/src/routes/index.tsx +9 -0
- package/dist/templates/react-ts/tsconfig.app.json +30 -0
- package/dist/templates/react-ts/tsconfig.json +10 -0
- package/dist/templates/react-ts/tsconfig.node.json +24 -0
- package/dist/{template → templates/react-ts}/vite.config.ts +9 -1
- package/dist/templates/react-ts-start/AGENTS.md +75 -0
- package/dist/templates/react-ts-start/README.md +58 -0
- package/dist/templates/react-ts-start/bun.lock +1688 -0
- package/dist/templates/react-ts-start/package.json +58 -0
- package/dist/templates/react-ts-start/src/routeTree.gen.ts +68 -0
- package/dist/templates/react-ts-start/src/router.tsx +18 -0
- package/dist/templates/react-ts-start/src/routes/__root.tsx +40 -0
- package/dist/templates/react-ts-start/src/routes/index.tsx +7 -0
- package/dist/templates/react-ts-start/vite.config.ts +29 -0
- package/package.json +3 -3
- package/dist/template/src/main.tsx +0 -10
- /package/dist/{template → templates/react-ts}/README.md +0 -0
- /package/dist/{template → templates/react-ts}/index.html +0 -0
- /package/dist/{template → templates/react-ts-start}/_gitignore +0 -0
- /package/dist/{template → templates/react-ts-start}/components.json +0 -0
- /package/dist/{template → templates/react-ts-start}/eslint.config.js +0 -0
- /package/dist/{template → templates/react-ts-start}/functions/.gitkeep +0 -0
- /package/dist/{template → templates/react-ts-start}/public/favicon.svg +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons/apple-touch-icon.png +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons/pwa-192x192.png +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons/pwa-512x512-maskable.png +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons/pwa-512x512.png +0 -0
- /package/dist/{template → templates/react-ts-start}/public/icons.svg +0 -0
- /package/dist/{template → templates/react-ts-start}/schema.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/server/db.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/src/App.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/animated-number.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/animated-text.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/bottom-nav.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/button.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/card.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/otp-input.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/scroll-affordance.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/sound.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/transitions.tsx +0 -0
- /package/dist/{template → templates/react-ts-start}/src/components/ui/vibes-ui-styles.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/src/db.drizzle.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/src/index.css +0 -0
- /package/dist/{template → templates/react-ts-start}/src/lib/utils.ts +0 -0
- /package/dist/{template → templates/react-ts-start}/tsconfig.app.json +0 -0
- /package/dist/{template → templates/react-ts-start}/tsconfig.json +0 -0
- /package/dist/{template → templates/react-ts-start}/tsconfig.node.json +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
3
|
+
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
4
|
+
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
5
|
+
</symbol>
|
|
6
|
+
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
7
|
+
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
8
|
+
</symbol>
|
|
9
|
+
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
10
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
11
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
12
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
13
|
+
</symbol>
|
|
14
|
+
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
15
|
+
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
16
|
+
</symbol>
|
|
17
|
+
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
18
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
19
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
20
|
+
</symbol>
|
|
21
|
+
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
22
|
+
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
23
|
+
</symbol>
|
|
24
|
+
</svg>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { better } from "better-drizzle"
|
|
2
|
+
import { drizzle } from "drizzle-orm/bun-sqlite"
|
|
3
|
+
import { db } from "@omg-dev/server"
|
|
4
|
+
import { drizzleSchema } from "../src/db.drizzle"
|
|
5
|
+
|
|
6
|
+
type BetterClient = ReturnType<typeof createBetterClient>
|
|
7
|
+
type ReadMethod =
|
|
8
|
+
| "count"
|
|
9
|
+
| "exists"
|
|
10
|
+
| "findFirst"
|
|
11
|
+
| "findMany"
|
|
12
|
+
| "findOne"
|
|
13
|
+
| "findUnique"
|
|
14
|
+
| "paginate"
|
|
15
|
+
type ReadOnlyDelegate<T> = Pick<T, Extract<keyof T, ReadMethod>>
|
|
16
|
+
type ReadOnlyBetterClient<T> = {
|
|
17
|
+
readonly [K in keyof T as K extends string
|
|
18
|
+
? K extends `$${string}` | "repository" | "transaction"
|
|
19
|
+
? never
|
|
20
|
+
: T[K] extends object
|
|
21
|
+
? K
|
|
22
|
+
: never
|
|
23
|
+
: never]: ReadOnlyDelegate<T[K]>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const READ_METHODS = new Set<string>([
|
|
27
|
+
"count",
|
|
28
|
+
"exists",
|
|
29
|
+
"findFirst",
|
|
30
|
+
"findMany",
|
|
31
|
+
"findOne",
|
|
32
|
+
"findUnique",
|
|
33
|
+
"paginate",
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
let drizzleClient: ReturnType<typeof createDrizzleClient> | null = null
|
|
37
|
+
let betterClient: ReturnType<typeof createBetterClient> | null = null
|
|
38
|
+
let betterReadClient: ReadOnlyBetterClient<BetterClient> | null = null
|
|
39
|
+
|
|
40
|
+
function createDrizzleClient() {
|
|
41
|
+
return drizzle(db.raw(), { schema: drizzleSchema })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createBetterClient() {
|
|
45
|
+
return better(getDrizzleDb(), { schema: drizzleSchema })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getDrizzleDb() {
|
|
49
|
+
drizzleClient ??= createDrizzleClient()
|
|
50
|
+
return drizzleClient
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getBetterDb() {
|
|
54
|
+
betterClient ??= createBetterClient()
|
|
55
|
+
return betterClient
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readOnlyDelegate(delegate: Record<string, unknown>, tableName: string) {
|
|
59
|
+
return new Proxy({}, {
|
|
60
|
+
get(_target, prop) {
|
|
61
|
+
if (typeof prop !== "string") return undefined
|
|
62
|
+
if (!READ_METHODS.has(prop)) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`[vibes] Better Drizzle helper is read-only. Use @omg-dev/server db or auto-CRUD for ${tableName}.${prop} mutations so auth scoping, timestamps, and live updates stay consistent.`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const value = delegate[prop]
|
|
69
|
+
return typeof value === "function" ? value.bind(delegate) : value
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createBetterReadClient(): ReadOnlyBetterClient<BetterClient> {
|
|
75
|
+
const client = getBetterDb() as Record<string, unknown>
|
|
76
|
+
const delegates = new Map<string, object>()
|
|
77
|
+
|
|
78
|
+
return new Proxy({}, {
|
|
79
|
+
get(_target, prop) {
|
|
80
|
+
if (typeof prop !== "string") return undefined
|
|
81
|
+
if (prop.startsWith("$") || prop === "repository" || prop === "transaction") {
|
|
82
|
+
throw new Error(`[vibes] Better Drizzle helper only exposes table read delegates.`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const value = client[prop]
|
|
86
|
+
if (!value || typeof value !== "object") return undefined
|
|
87
|
+
|
|
88
|
+
let delegate = delegates.get(prop)
|
|
89
|
+
if (!delegate) {
|
|
90
|
+
delegate = readOnlyDelegate(value as Record<string, unknown>, prop)
|
|
91
|
+
delegates.set(prop, delegate)
|
|
92
|
+
}
|
|
93
|
+
return delegate
|
|
94
|
+
},
|
|
95
|
+
}) as ReadOnlyBetterClient<BetterClient>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getBetterReadDb() {
|
|
99
|
+
betterReadClient ??= createBetterReadClient()
|
|
100
|
+
return betterReadClient
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { drizzleSchema }
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import {
|
|
3
|
+
Bot,
|
|
4
|
+
Lightbulb,
|
|
5
|
+
Database,
|
|
6
|
+
Home,
|
|
7
|
+
ImagePlus,
|
|
8
|
+
KeyRound,
|
|
9
|
+
Mic,
|
|
10
|
+
User,
|
|
11
|
+
Zap,
|
|
12
|
+
} from "lucide-react"
|
|
13
|
+
import { AnimatedNumber } from "@/components/ui/animated-number"
|
|
14
|
+
import { AnimatedText } from "@/components/ui/animated-text"
|
|
15
|
+
import { ScrollAffordance } from "@/components/ui/scroll-affordance"
|
|
16
|
+
import { Card } from "@/components/ui/card"
|
|
17
|
+
import { BottomNav } from "@/components/ui/bottom-nav"
|
|
18
|
+
|
|
19
|
+
const verbs = ["build", "ship", "iterate", "launch", "design"]
|
|
20
|
+
|
|
21
|
+
const ideas = [
|
|
22
|
+
{
|
|
23
|
+
title: "Add sign-in",
|
|
24
|
+
prompt: "Add sign-in with an email code",
|
|
25
|
+
icon: KeyRound,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
title: "Save data",
|
|
29
|
+
prompt: "Add a posts collection and a form to create them",
|
|
30
|
+
icon: Database,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
title: "Add AI chat",
|
|
34
|
+
prompt: "Add a chat box that streams from Claude",
|
|
35
|
+
icon: Bot,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
title: "Server endpoint",
|
|
39
|
+
prompt: "Add an API route that returns JSON",
|
|
40
|
+
icon: Zap,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
title: "Upload files",
|
|
44
|
+
prompt: "Let users upload an avatar image and show it on their profile",
|
|
45
|
+
icon: ImagePlus,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
title: "Add voice",
|
|
49
|
+
prompt: "Add a button that reads the page summary aloud with generated speech",
|
|
50
|
+
icon: Mic,
|
|
51
|
+
},
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
// The vendored bottom tab bar (src/components/ui/bottom-nav.tsx) is a
|
|
55
|
+
// controlled component — drive `active`/`onSelect` from state or your router.
|
|
56
|
+
const tabs = [
|
|
57
|
+
{ id: "home", label: "Home", Icon: Home },
|
|
58
|
+
{ id: "ideas", label: "Ideas", Icon: Lightbulb },
|
|
59
|
+
{ id: "you", label: "You", Icon: User },
|
|
60
|
+
] as const
|
|
61
|
+
|
|
62
|
+
function App() {
|
|
63
|
+
// Taste-by-default delight primitives, vendored as editable source under
|
|
64
|
+
// src/components/ui/. The count rolls up from 0 on first paint
|
|
65
|
+
// (<AnimatedNumber>), the headline verb flips on a timer (<AnimatedText>),
|
|
66
|
+
// and <ScrollAffordance> is a self-aware "more below" cue that shows only
|
|
67
|
+
// when the page overflows. All zero-dep and themed from your shadcn
|
|
68
|
+
// variables — open the files and restyle freely.
|
|
69
|
+
const [count, setCount] = useState(0)
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const id = requestAnimationFrame(() => setCount(ideas.length))
|
|
72
|
+
return () => cancelAnimationFrame(id)
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
const [verbIndex, setVerbIndex] = useState(0)
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const id = setInterval(
|
|
78
|
+
() => setVerbIndex((i) => (i + 1) % verbs.length),
|
|
79
|
+
2200,
|
|
80
|
+
)
|
|
81
|
+
return () => clearInterval(id)
|
|
82
|
+
}, [])
|
|
83
|
+
|
|
84
|
+
const [tab, setTab] = useState<(typeof tabs)[number]["id"]>("home")
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<main className="min-h-svh bg-background text-foreground flex flex-col items-center justify-center px-6 pt-12 pb-28">
|
|
88
|
+
<div className="w-full max-w-xl">
|
|
89
|
+
<header className="mb-8 text-center">
|
|
90
|
+
<h1 className="text-2xl font-semibold tracking-tight inline-flex items-baseline gap-2 flex-wrap justify-center">
|
|
91
|
+
<span>What do you want to</span>
|
|
92
|
+
<AnimatedText value={verbs[verbIndex]} className="text-primary" />
|
|
93
|
+
</h1>
|
|
94
|
+
<p className="text-sm text-muted-foreground mt-2">
|
|
95
|
+
<AnimatedNumber value={count} className="font-medium text-foreground" />{" "}
|
|
96
|
+
ways to start.
|
|
97
|
+
</p>
|
|
98
|
+
</header>
|
|
99
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
100
|
+
{ideas.map(({ title, prompt, icon: Icon }) => (
|
|
101
|
+
<Card
|
|
102
|
+
key={title}
|
|
103
|
+
className="gap-2 p-4 transition-colors hover:bg-accent/40"
|
|
104
|
+
>
|
|
105
|
+
<Icon
|
|
106
|
+
className="size-5 text-muted-foreground mb-1"
|
|
107
|
+
aria-hidden="true"
|
|
108
|
+
/>
|
|
109
|
+
<h2 className="text-sm font-medium">{title}</h2>
|
|
110
|
+
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
111
|
+
{prompt}
|
|
112
|
+
</p>
|
|
113
|
+
</Card>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
<div className="mt-10 flex justify-center">
|
|
117
|
+
<ScrollAffordance label="More" />
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* The omg home-screen bottom bar, now vendored. Swap these tabs for
|
|
122
|
+
your app's sections and wire onSelect to your router. */}
|
|
123
|
+
<BottomNav tabs={tabs} active={tab} onSelect={setTab} />
|
|
124
|
+
</main>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export default App
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// <AnimatedNumber> — odometer rolling digits (the number half of slot-text;
|
|
2
|
+
// its sibling <AnimatedText> rolls arbitrary letters).
|
|
3
|
+
//
|
|
4
|
+
// Vendored, editable source (was @omg-dev/ui). Any number that changes deserves
|
|
5
|
+
// to *move*: counts, prices, stats. Self-contained — no Tailwind utility
|
|
6
|
+
// classes, no CSS import beyond the injected `.vui-*` stylesheet, no runtime
|
|
7
|
+
// dependency beyond React. Themes itself from your shadcn CSS variables
|
|
8
|
+
// (--foreground) with light/dark fallbacks and honors prefers-reduced-motion.
|
|
9
|
+
|
|
10
|
+
import { useEffect, type CSSProperties } from "react"
|
|
11
|
+
import { ensureStyles } from "@/components/ui/vibes-ui-styles"
|
|
12
|
+
|
|
13
|
+
const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
|
|
14
|
+
|
|
15
|
+
export type AnimatedNumberProps = {
|
|
16
|
+
/** The value to display. Changing it rolls the digits to the new number. */
|
|
17
|
+
value: number
|
|
18
|
+
/**
|
|
19
|
+
* Format the raw value into the string that gets rendered. Defaults to the
|
|
20
|
+
* host locale's grouped integer (e.g. `1,234`). Any digit in the output
|
|
21
|
+
* animates; everything else (separators, currency, units) stays static.
|
|
22
|
+
*/
|
|
23
|
+
format?: (value: number) => string
|
|
24
|
+
className?: string
|
|
25
|
+
style?: CSSProperties
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rolling-digit number. Each digit is a vertical reel of 0–9 that translates
|
|
30
|
+
* to the target digit; CSS handles the motion, so re-rendering with a new
|
|
31
|
+
* `value` is all it takes to animate. Significant digits cascade in slightly
|
|
32
|
+
* after the small ones for an odometer feel.
|
|
33
|
+
*/
|
|
34
|
+
export function AnimatedNumber({
|
|
35
|
+
value,
|
|
36
|
+
format = defaultFormat,
|
|
37
|
+
className = "",
|
|
38
|
+
style,
|
|
39
|
+
}: AnimatedNumberProps) {
|
|
40
|
+
useEffect(ensureStyles, [])
|
|
41
|
+
|
|
42
|
+
const text = format(value)
|
|
43
|
+
const chars = Array.from(text)
|
|
44
|
+
// Stagger from the least-significant character so the ripple travels left.
|
|
45
|
+
const lastIndex = chars.length - 1
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<span
|
|
49
|
+
className={`vui vui-num ${className}`.trim()}
|
|
50
|
+
style={style}
|
|
51
|
+
role="text"
|
|
52
|
+
aria-label={text}
|
|
53
|
+
>
|
|
54
|
+
{chars.map((char, i) => {
|
|
55
|
+
if (!isDigit(char)) {
|
|
56
|
+
return (
|
|
57
|
+
<span key={i} className="vui-num-sym" aria-hidden="true">
|
|
58
|
+
{char}
|
|
59
|
+
</span>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
const digit = char.charCodeAt(0) - 48
|
|
63
|
+
const fromRight = lastIndex - i
|
|
64
|
+
return (
|
|
65
|
+
<Reel key={i} digit={digit} delay={Math.min(fromRight, 6) * 0.05} />
|
|
66
|
+
)
|
|
67
|
+
})}
|
|
68
|
+
</span>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function Reel({ digit, delay }: { digit: number; delay: number }) {
|
|
73
|
+
return (
|
|
74
|
+
<span className="vui-num-reel" aria-hidden="true">
|
|
75
|
+
<span
|
|
76
|
+
className="vui-num-track"
|
|
77
|
+
style={{
|
|
78
|
+
transform: `translateY(${-digit}em)`,
|
|
79
|
+
transitionDelay: `${delay}s`,
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{DIGITS.map((d) => (
|
|
83
|
+
<span key={d} className="vui-num-cell">
|
|
84
|
+
{d}
|
|
85
|
+
</span>
|
|
86
|
+
))}
|
|
87
|
+
</span>
|
|
88
|
+
</span>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isDigit(char: string) {
|
|
93
|
+
return char.length === 1 && char >= "0" && char <= "9"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function defaultFormat(value: number): string {
|
|
97
|
+
try {
|
|
98
|
+
return new Intl.NumberFormat().format(value)
|
|
99
|
+
} catch {
|
|
100
|
+
return String(value)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// <AnimatedText> — split-flap / slot-text rolling letters.
|
|
2
|
+
//
|
|
3
|
+
// The text half of the slot-text effect (its sibling <AnimatedNumber> rolls
|
|
4
|
+
// digits). Any word that changes deserves to *move*: a rotating headline verb,
|
|
5
|
+
// a status that flips, a name that resolves. Each character position is a
|
|
6
|
+
// vertical reel of glyphs that translates to the target letter — like a
|
|
7
|
+
// split-flap departure board. Self-contained — no Tailwind utility classes, no
|
|
8
|
+
// CSS import beyond the injected `.vui-*` stylesheet, no runtime dependency
|
|
9
|
+
// beyond React. Themes itself from your shadcn CSS variables (--foreground)
|
|
10
|
+
// with light/dark fallbacks and honors prefers-reduced-motion.
|
|
11
|
+
|
|
12
|
+
import { useEffect, useMemo, type CSSProperties } from "react"
|
|
13
|
+
import { ensureStyles } from "@/components/ui/vibes-ui-styles"
|
|
14
|
+
|
|
15
|
+
// The reel alphabet — every glyph a character can roll *through*. Space sits
|
|
16
|
+
// first so blanks rest at the top of the reel. Any character in `value` that
|
|
17
|
+
// isn't here renders static (no reel), the way <AnimatedNumber> leaves
|
|
18
|
+
// separators and currency symbols still.
|
|
19
|
+
const ALPHABET = Array.from(
|
|
20
|
+
" ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,!?'-&@",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
export type AnimatedTextProps = {
|
|
24
|
+
/** The text to display. Changing it rolls each letter to its new glyph. */
|
|
25
|
+
value: string
|
|
26
|
+
/**
|
|
27
|
+
* Override the glyphs a reel can roll through. Defaults to spaces, letters,
|
|
28
|
+
* digits and common punctuation. A tighter set (e.g. just `"ABC…Z"`) makes
|
|
29
|
+
* the roll shorter and the board feel snappier.
|
|
30
|
+
*/
|
|
31
|
+
alphabet?: string
|
|
32
|
+
className?: string
|
|
33
|
+
style?: CSSProperties
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Rolling-letter text. Each character is a vertical reel of the alphabet that
|
|
38
|
+
* translates to the target glyph; CSS handles the motion, so re-rendering with
|
|
39
|
+
* a new `value` is all it takes to animate. Characters reveal left-to-right so
|
|
40
|
+
* the roll reads like the word is being dealt out.
|
|
41
|
+
*/
|
|
42
|
+
export function AnimatedText({
|
|
43
|
+
value,
|
|
44
|
+
alphabet,
|
|
45
|
+
className = "",
|
|
46
|
+
style,
|
|
47
|
+
}: AnimatedTextProps) {
|
|
48
|
+
useEffect(ensureStyles, [])
|
|
49
|
+
|
|
50
|
+
// Resolve the reel alphabet and a glyph→row lookup once per alphabet.
|
|
51
|
+
const { set, index } = useMemo(() => {
|
|
52
|
+
const chars = alphabet ? Array.from(alphabet) : ALPHABET
|
|
53
|
+
return { set: chars, index: new Map(chars.map((c, i) => [c, i])) }
|
|
54
|
+
}, [alphabet])
|
|
55
|
+
|
|
56
|
+
const chars = Array.from(value)
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<span
|
|
60
|
+
className={`vui vui-text ${className}`.trim()}
|
|
61
|
+
style={style}
|
|
62
|
+
role="text"
|
|
63
|
+
aria-label={value}
|
|
64
|
+
>
|
|
65
|
+
{chars.map((char, i) => {
|
|
66
|
+
const row = index.get(char)
|
|
67
|
+
if (row === undefined) {
|
|
68
|
+
return (
|
|
69
|
+
<span key={i} className="vui-text-sym" aria-hidden="true">
|
|
70
|
+
{char}
|
|
71
|
+
</span>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
// Reveal left-to-right; cap the stagger so long strings still settle.
|
|
75
|
+
return (
|
|
76
|
+
<Reel key={i} row={row} set={set} delay={Math.min(i, 8) * 0.06} />
|
|
77
|
+
)
|
|
78
|
+
})}
|
|
79
|
+
</span>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function Reel({
|
|
84
|
+
row,
|
|
85
|
+
set,
|
|
86
|
+
delay,
|
|
87
|
+
}: {
|
|
88
|
+
row: number
|
|
89
|
+
set: string[]
|
|
90
|
+
delay: number
|
|
91
|
+
}) {
|
|
92
|
+
return (
|
|
93
|
+
<span className="vui-text-reel" aria-hidden="true">
|
|
94
|
+
<span
|
|
95
|
+
className="vui-text-track"
|
|
96
|
+
style={{
|
|
97
|
+
transform: `translateY(${-row}em)`,
|
|
98
|
+
transitionDelay: `${delay}s`,
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
{set.map((c, i) => (
|
|
102
|
+
<span key={i} className="vui-text-cell">
|
|
103
|
+
{c === " " ? " " : c}
|
|
104
|
+
</span>
|
|
105
|
+
))}
|
|
106
|
+
</span>
|
|
107
|
+
</span>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Floating bottom tab bar — the iOS-style pill switcher from the omg home
|
|
2
|
+
// screen, vendored for apps. A translucent blurred pill wrapped in a gradient
|
|
3
|
+
// white hairline that catches the light along its top edge (the way an iOS
|
|
4
|
+
// material does), with an active capsule that *slides* between tabs via a pure
|
|
5
|
+
// GPU translate.
|
|
6
|
+
//
|
|
7
|
+
// This is the presentational, controlled component: pass it your tabs, the
|
|
8
|
+
// active id, and an onSelect handler. It owns no routing — wire `active` /
|
|
9
|
+
// `onSelect` to your router or local state.
|
|
10
|
+
//
|
|
11
|
+
// const [tab, setTab] = useState("home")
|
|
12
|
+
// <BottomNav
|
|
13
|
+
// tabs={[
|
|
14
|
+
// { id: "home", label: "Home", Icon: Home },
|
|
15
|
+
// { id: "browse", label: "Browse", Icon: Compass },
|
|
16
|
+
// { id: "profile", label: "Profile", Icon: User },
|
|
17
|
+
// ]}
|
|
18
|
+
// active={tab}
|
|
19
|
+
// onSelect={setTab}
|
|
20
|
+
// />
|
|
21
|
+
//
|
|
22
|
+
// Self-contained: colours read the app's shadcn theme vars (--primary,
|
|
23
|
+
// --background, --muted-foreground) so it follows whatever preset the app uses.
|
|
24
|
+
// Vendored — edit freely.
|
|
25
|
+
|
|
26
|
+
import type { ComponentType, CSSProperties } from "react"
|
|
27
|
+
|
|
28
|
+
import { cn } from "@/lib/utils"
|
|
29
|
+
|
|
30
|
+
// Tab width and gap are fixed so the sliding capsule can be a pure translate —
|
|
31
|
+
// the only animated property (GPU-cheap, interruptible). TAB_GAP must match the
|
|
32
|
+
// `gap-1` between tab buttons.
|
|
33
|
+
const TAB_W = "4.5rem"
|
|
34
|
+
const TAB_GAP = "0.25rem"
|
|
35
|
+
// The omg motion curve (Ionic's iOS drawer easing).
|
|
36
|
+
const EASE = "cubic-bezier(0.32, 0.72, 0, 1)"
|
|
37
|
+
|
|
38
|
+
// A whisper of haptic feedback on tap where the platform supports it (iOS
|
|
39
|
+
// Safari ignores it; Android/Chrome buzz ~10ms). No-op everywhere else.
|
|
40
|
+
function haptic() {
|
|
41
|
+
if (typeof navigator !== "undefined" && "vibrate" in navigator) {
|
|
42
|
+
try {
|
|
43
|
+
navigator.vibrate(8)
|
|
44
|
+
} catch {
|
|
45
|
+
/* feature-detected but disallowed (e.g. no user gesture) — ignore */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type BottomNavItem<T extends string> = {
|
|
51
|
+
id: T
|
|
52
|
+
label: string
|
|
53
|
+
Icon: ComponentType<{ className?: string }>
|
|
54
|
+
// Visible but inert — rendered dimmed; taps don't fire onSelect.
|
|
55
|
+
disabled?: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function BottomNav<T extends string>({
|
|
59
|
+
tabs,
|
|
60
|
+
active,
|
|
61
|
+
onSelect,
|
|
62
|
+
className,
|
|
63
|
+
}: {
|
|
64
|
+
tabs: ReadonlyArray<BottomNavItem<T>>
|
|
65
|
+
active: T
|
|
66
|
+
onSelect: (id: T) => void
|
|
67
|
+
className?: string
|
|
68
|
+
}) {
|
|
69
|
+
const activeIndex = Math.max(
|
|
70
|
+
0,
|
|
71
|
+
tabs.findIndex((t) => t.id === active),
|
|
72
|
+
)
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
className={cn(
|
|
76
|
+
"pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center pb-[calc(0.875rem+env(safe-area-inset-bottom))]",
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{/* Gradient hairline: brightest on the top edge, fading down — the
|
|
81
|
+
border itself is the p-px ring around the blurred pill. */}
|
|
82
|
+
<div className="pointer-events-auto rounded-full bg-gradient-to-b from-white/70 via-white/25 to-white/10 p-px shadow-[0_8px_28px_oklch(0_0_0/14%)] dark:from-white/25 dark:via-white/10 dark:to-white/5">
|
|
83
|
+
<nav className="relative flex items-center gap-1 rounded-full bg-background/85 p-1.5 backdrop-blur-xl">
|
|
84
|
+
{/* Sliding capsule: translate by activeIndex × (tab width + gap). */}
|
|
85
|
+
<span
|
|
86
|
+
aria-hidden
|
|
87
|
+
className="absolute inset-y-1.5 left-1.5 w-[var(--tab-w)] rounded-full bg-primary/12 transition-transform duration-[260ms]"
|
|
88
|
+
style={
|
|
89
|
+
{
|
|
90
|
+
"--tab-w": TAB_W,
|
|
91
|
+
transitionTimingFunction: EASE,
|
|
92
|
+
transform: `translateX(calc(${activeIndex} * (${TAB_W} + ${TAB_GAP})))`,
|
|
93
|
+
} as CSSProperties
|
|
94
|
+
}
|
|
95
|
+
/>
|
|
96
|
+
{tabs.map(({ id, label, Icon, disabled }) => {
|
|
97
|
+
const selected = id === active
|
|
98
|
+
return (
|
|
99
|
+
<button
|
|
100
|
+
key={id}
|
|
101
|
+
type="button"
|
|
102
|
+
aria-label={label}
|
|
103
|
+
aria-current={selected ? "page" : undefined}
|
|
104
|
+
aria-disabled={disabled || undefined}
|
|
105
|
+
onClick={() => {
|
|
106
|
+
if (disabled) return
|
|
107
|
+
haptic()
|
|
108
|
+
if (!selected) onSelect(id)
|
|
109
|
+
}}
|
|
110
|
+
className={cn(
|
|
111
|
+
"relative flex w-[var(--tab-w)] flex-col items-center gap-0.5 rounded-full px-4 py-1.5 transition-[color,transform] duration-200 ease-out",
|
|
112
|
+
disabled
|
|
113
|
+
? "text-muted-foreground/40"
|
|
114
|
+
: selected
|
|
115
|
+
? "text-primary active:scale-[0.96]"
|
|
116
|
+
: "text-muted-foreground hover:text-foreground active:scale-[0.96]",
|
|
117
|
+
)}
|
|
118
|
+
style={{ "--tab-w": TAB_W } as CSSProperties}
|
|
119
|
+
>
|
|
120
|
+
<Icon className="h-[18px] w-[18px]" />
|
|
121
|
+
<span className="text-[10.5px] font-medium tracking-[-0.01em]">
|
|
122
|
+
{label}
|
|
123
|
+
</span>
|
|
124
|
+
</button>
|
|
125
|
+
)
|
|
126
|
+
})}
|
|
127
|
+
</nav>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|