├── .editorconfig ├── pages ├── _layouts │ ├── index.css │ └── index.tsx ├── 404.tsx ├── _document.tsx ├── admin │ └── index.tsx ├── stories │ ├── [id] │ │ ├── processing.tsx │ │ └── index.tsx │ └── create.tsx └── index.tsx ├── postcss.config.js ├── .gitignore ├── server ├── nokkio.d.ts ├── ai │ ├── index.ts │ ├── clients.ts │ ├── embeddings.ts │ └── generation.ts ├── deno.json ├── endpoints │ ├── stories │ │ └── [id] │ │ │ └── related.ts │ └── auth.ts ├── schedules │ └── [day] │ │ └── 21 │ │ └── 00.ts └── boot.ts ├── prettier.config.js ├── utils └── media.ts ├── README.md ├── tailwind.config.js ├── tsconfig.json ├── components ├── Spinner.tsx ├── Footer.tsx ├── Progress.tsx └── SignInWithGoogleButton.tsx ├── package.json ├── LICENSE └── schema.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_size = 2 -------------------------------------------------------------------------------- /pages/_layouts/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('tailwindcss'), require('autoprefixer')], 3 | }; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .nokkio.log 4 | .nokkio-files 5 | dev.sqlite 6 | server/bundle-* 7 | server/vendor -------------------------------------------------------------------------------- /server/nokkio.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | const NOKKIO_ENV: Record; 3 | } 4 | 5 | export {}; 6 | -------------------------------------------------------------------------------- /server/ai/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | generateAudio, 3 | generateImage, 4 | generateStory, 5 | } from 'server/ai/generation.ts'; 6 | 7 | export { 8 | findSimilarStories, 9 | updateEmbeddingForStory, 10 | } from 'server/ai/embeddings.ts'; 11 | -------------------------------------------------------------------------------- /server/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "importMap": "node_modules/@nokkio/import_map.json", 3 | "lint": { 4 | "exclude": [ 5 | "bundle-*" 6 | ] 7 | }, 8 | "compilerOptions": { 9 | "types": [ 10 | "./nokkio.d.ts" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | bracketSpacing: true, 4 | printWidth: 80, 5 | requirePragma: false, 6 | semi: true, 7 | singleQuote: true, 8 | tabWidth: 2, 9 | trailingComma: 'all', 10 | useTabs: false, 11 | }; 12 | -------------------------------------------------------------------------------- /utils/media.ts: -------------------------------------------------------------------------------- 1 | export function secondsToHumanReadable(input: number) { 2 | if (isNaN(input)) { 3 | return ''; 4 | } 5 | 6 | const n = Math.round(input); 7 | const m = Math.floor(n / 60); 8 | const s = n % 60; 9 | 10 | return `${m}:${s < 10 ? `0${s}` : s}`; 11 | } 12 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import type { PageMetadataFunction } from '@nokkio/router'; 2 | 3 | import type { JSX } from "react"; 4 | 5 | export const getPageMetadata: PageMetadataFunction = () => { 6 | return { http: { status: 404 }, title: 'Not found' }; 7 | }; 8 | 9 | export default function NotFound(): JSX.Element { 10 | return

404 / Not Found

; 11 | } 12 | -------------------------------------------------------------------------------- /server/ai/clients.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'npm:openai@^4.83'; 2 | import { Pinecone } from 'npm:@pinecone-database/pinecone@^2.2'; 3 | 4 | import { getSecret } from '@nokkio/endpoints'; 5 | 6 | export const openai = new OpenAI({ 7 | apiKey: getSecret('openAIApiKey'), 8 | }); 9 | 10 | export const pinecone = new Pinecone({ 11 | apiKey: getSecret('pineconeApiKey'), 12 | }); 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tonight's Bedtime Story: An experiment with the OpenAI API 2 | 3 | This repo hosts the code that runs [bedtime-ai.nokk.io](https://bedtime-ai.nokk.io). It uses the OpenAI API to generate unique bedtime stories from a prompt, including a cover image for the story and an audio reading for playback in a browser. 4 | 5 | The app is built on [Nokkio](https://www.nokk.io/), a fully managed React framework. 6 | -------------------------------------------------------------------------------- /pages/_layouts/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, type JSX } from 'react'; 2 | 3 | import './index.css'; 4 | 5 | export default function PageLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }): JSX.Element { 10 | return ( 11 |
12 | {children} 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /server/endpoints/stories/[id]/related.ts: -------------------------------------------------------------------------------- 1 | import { findSimilarStories } from 'server/ai/index.ts'; 2 | import { Story } from '@nokkio/magic'; 3 | import { NokkioRequest, json } from '@nokkio/endpoints'; 4 | 5 | export async function get(req: NokkioRequest) { 6 | const story = await Story.findById(req.params.id as string); 7 | 8 | if (!story) { 9 | return json({ error: 'not found' }, { status: 404 }); 10 | } 11 | 12 | const relatedStories = await findSimilarStories(story); 13 | 14 | return json({ story, relatedStories }); 15 | } 16 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme'); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: ['./{pages,components}/**/*.{js,tsx}'], 6 | theme: { 7 | extend: { 8 | height: { 9 | // Use dvh when able, falling back for old browsers. 10 | // fixes mobile screen height 11 | screen: ['100vh', '100dvh'], 12 | }, 13 | fontFamily: { 14 | sans: ['Andika', ...defaultTheme.fontFamily.sans], 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "baseUrl": ".", 18 | "jsx": "react-jsx" 19 | }, 20 | "exclude": ["./server/*"] 21 | } 22 | -------------------------------------------------------------------------------- /components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | export default function Spinner() { 2 | return ( 3 | 9 | 17 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deno-node-testbed", 3 | "private": true, 4 | "version": "0.0.1", 5 | "description": "", 6 | "main": "index.js", 7 | "dependencies": { 8 | "@types/google.accounts": "^0.0.14", 9 | "@types/react-dom": "^19.0.3", 10 | "react": "^19.0.0", 11 | "react-dom": "^19.0.0" 12 | }, 13 | "devDependencies": { 14 | "@types/react": "^19.0.8", 15 | "autoprefixer": "^10.4.14", 16 | "postcss": "^8.4.21", 17 | "prettier-plugin-tailwindcss": "^0.2.5", 18 | "tailwindcss": "^3.3.0", 19 | "typescript": "^5.2.0" 20 | }, 21 | "scripts": { 22 | "test": "echo \"Error: no test specified\" && exit 1", 23 | "nokkio:ci": "tsc && nokkio typecheck-server" 24 | }, 25 | "author": "", 26 | "license": "UNLICENSED", 27 | "nokkio": { 28 | "id": "2ZdbUsho0QOFtHlBB87hOfYDqTc", 29 | "version": "v0.0.74" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /server/schedules/[day]/21/00.ts: -------------------------------------------------------------------------------- 1 | import { Story, User } from '@nokkio/magic'; 2 | 3 | // Using Nokkio's scheduling feature, create a new public story every day 4 | // at 21:00 UTC. 5 | export default async function () { 6 | const [user] = await User.find({ filter: { email: 'brad.daily@gmail.com' } }); 7 | 8 | if (!user) { 9 | return; 10 | } 11 | 12 | const recent = await Story.find({ 13 | filter: { 14 | isDailyStory: true, 15 | isPublic: true, 16 | }, 17 | sort: '-createdAt', 18 | limit: 5, 19 | }); 20 | 21 | const history = recent 22 | .map((r) => `Title: ${r.title}\nStory: ${r.text}`) 23 | .join('\n---\n'); 24 | 25 | await user.createStory({ 26 | prompt: `write a unique bedtime story. Here are the last 5 stories you created, the new story should not use the same characters, themes, or storylines: ${history}`, 27 | isPublic: true, 28 | isDailyStory: true, 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@nokkio/router'; 2 | 3 | export default function Footer() { 4 | return ( 5 |
6 |
7 | Tonight's Bedtime Story:{' '} 8 | 9 | View other stories 10 | {' '} 11 | or{' '} 12 | 13 | create your own 14 | 15 | . 16 |
17 |
18 | Built by{' '} 19 | 24 | @bradleyboy 25 | {' '} 26 | with{' '} 27 | 28 | Nokkio 29 | {' '} 30 | and the OpenAI API. 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Brad Daily 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /components/Progress.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | 3 | export default function Progress({ expectedTime }: { expectedTime: number }) { 4 | const [p, setP] = useState(0); 5 | const startTime = useRef(Date.now()); 6 | 7 | useEffect(() => { 8 | const intervalId = setInterval(() => { 9 | const nextP = 10 | 1 - Math.exp(-2 * ((Date.now() - startTime.current) / expectedTime)); 11 | setP(nextP); 12 | }, 16); 13 | 14 | return () => clearInterval(intervalId); 15 | }, []); 16 | 17 | return ( 18 | 24 | 32 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /schema.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@nokkio/schema').Config} */ 2 | module.exports = function ({ defineModel, types }) { 3 | const User = defineModel('User', { 4 | sub: types.string().unique(), 5 | email: types.string().filterable(), 6 | name: types.string(), 7 | picture: types.string(), 8 | isAdmin: types.bool(false), 9 | isBanned: types.bool(false), 10 | }); 11 | 12 | const Story = defineModel('Story', { 13 | prompt: types.text(), 14 | state: types 15 | .string('created') 16 | .oneOf([ 17 | 'created', 18 | 'generating_story', 19 | 'generating_media', 20 | 'ready', 21 | 'failed', 22 | ]) 23 | .filterable(), 24 | imagePrompt: types.text(null), 25 | title: types.string(null), 26 | summary: types.text(null), 27 | text: types.text(null), 28 | duration: types.number(null), 29 | image: types.image(null), 30 | audio: types.text(null), 31 | attempt: types.number(1), 32 | completedAt: types.datetime(null), 33 | isPublic: types.bool(false), 34 | isDailyStory: types.bool(false), 35 | }); 36 | 37 | // Enforce model event ordering only at the record 38 | // level to reduce latency / contention. 39 | Story.orderEventsByRecord(); 40 | 41 | Story.belongsTo(Story, { optional: true, name: 'parentStory' }); 42 | User.hasMany(Story); 43 | User.actAsAuth({ type: 'custom' }); 44 | 45 | return { Story, User }; 46 | }; 47 | -------------------------------------------------------------------------------- /components/SignInWithGoogleButton.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState, type JSX } from 'react'; 2 | 3 | import { makeRequest } from '@nokkio/endpoints'; 4 | 5 | function initializeGoogleAuth() { 6 | const nonce = Math.random().toString(36).slice(2); 7 | 8 | google.accounts.id.initialize({ 9 | client_id: 10 | '752773946224-6at0hiisl0754fb7ar02crint6rrsj2p.apps.googleusercontent.com', 11 | nonce, 12 | callback: function ({ credential }) { 13 | handleCredential(credential, nonce); 14 | }, 15 | }); 16 | } 17 | 18 | function renderButton(element: HTMLElement) { 19 | google.accounts.id.renderButton(element, { 20 | type: 'standard', 21 | size: 'medium', 22 | theme: 'filled_black', 23 | }); 24 | } 25 | 26 | async function handleCredential(credential: string, nonce: string) { 27 | const form = new FormData(); 28 | form.set('credential', credential); 29 | form.set('nonce', nonce); 30 | 31 | await makeRequest('/auth', { 32 | method: 'POST', 33 | body: form, 34 | }); 35 | } 36 | 37 | function GoogleAuth({ children }: { children: JSX.Element }) { 38 | const [isInitialized, setInitialized] = useState(false); 39 | 40 | useEffect(() => { 41 | if (window.google) { 42 | initializeGoogleAuth(); 43 | setInitialized(true); 44 | } else { 45 | window.onload = () => { 46 | initializeGoogleAuth(); 47 | setInitialized(true); 48 | }; 49 | } 50 | }, []); 51 | 52 | if (!isInitialized) { 53 | return null; 54 | } 55 | 56 | return children; 57 | } 58 | 59 | function SignInButton() { 60 | const ref = useRef(null); 61 | 62 | useEffect(() => { 63 | renderButton(ref.current as HTMLElement); 64 | }, []); 65 | 66 | return
; 67 | } 68 | 69 | export default function SignInWithGoogleButton() { 70 | return ( 71 | 72 | 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { NOKKIO_CSP_NONCE, Html, Head, Body, DocumentProps } from '@nokkio/doc'; 2 | 3 | import type { JSX } from "react"; 4 | 5 | const gtagInit = ` 6 | window.dataLayer = window.dataLayer || []; 7 | function gtag(){dataLayer.push(arguments);} 8 | gtag('js', new Date()); 9 | 10 | gtag('config', 'G-9K0VDCYFPJ'); 11 | `; 12 | 13 | const siteMetadata = ` 14 | { 15 | "@context" : "https://schema.org", 16 | "@type" : "WebSite", 17 | "name" : "Tonight's Bedtime Story", 18 | "url" : "https://bedtime-ai.nokk.io/" 19 | } 20 | `; 21 | 22 | export default function Document({ children }: DocumentProps): JSX.Element { 23 | return ( 24 | 25 | 26 | 31 | 35 | 36 | 37 | 38 | 42 | 43 | 48 | 53 | 57 | 58 | 59 |
{children}
60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /server/endpoints/auth.ts: -------------------------------------------------------------------------------- 1 | import { NokkioRequest, ok } from '@nokkio/endpoints'; 2 | import { User } from '@nokkio/magic'; 3 | import { login } from '@nokkio/auth'; 4 | 5 | import { 6 | jwtVerify, 7 | createRemoteJWKSet, 8 | } from 'https://deno.land/x/jose@v4.5.0/index.ts'; 9 | 10 | const JWKS = createRemoteJWKSet( 11 | new URL('https://www.googleapis.com/oauth2/v3/certs'), 12 | ); 13 | 14 | const CLIENT_ID = 15 | '752773946224-6at0hiisl0754fb7ar02crint6rrsj2p.apps.googleusercontent.com'; 16 | 17 | export async function post(req: NokkioRequest) { 18 | const fd = await req.formData(); 19 | const credential = fd.get('credential'); 20 | const nonce = fd.get('nonce'); 21 | 22 | if (credential === null || typeof credential !== 'string') { 23 | return new Response('Invalid request', { status: 400 }); 24 | } 25 | 26 | try { 27 | const result = await jwtVerify(credential, JWKS, { 28 | issuer: 'https://accounts.google.com', 29 | audience: CLIENT_ID, 30 | }); 31 | 32 | const payload = result.payload as { 33 | aud: string; 34 | iss: string; 35 | sub: string; 36 | email: string; 37 | picture: string; 38 | name: string; 39 | exp: number; 40 | nonce: string; 41 | }; 42 | 43 | if (payload.nonce !== nonce) { 44 | return new Response('Validation Error: nonce does not match', { 45 | status: 400, 46 | }); 47 | } 48 | 49 | if (payload.aud !== CLIENT_ID) { 50 | return new Response('Validation Error: iad does not match client ID', { 51 | status: 400, 52 | }); 53 | } 54 | 55 | if (!/^(https:\/\/)?accounts.google.com$/.test(payload.iss)) { 56 | return new Response('Validation Error: invalid iss', { 57 | status: 400, 58 | }); 59 | } 60 | 61 | if (new Date() > new Date(payload.exp * 1000)) { 62 | return new Response('Validation Error: expired', { 63 | status: 400, 64 | }); 65 | } 66 | 67 | const { sub, email, name, picture } = payload; 68 | 69 | let [user] = await User.find({ filter: { sub } }); 70 | 71 | if (user) { 72 | await user.update({ email, name, picture }); 73 | } else { 74 | user = await User.create({ 75 | sub, 76 | email, 77 | name, 78 | picture, 79 | }); 80 | } 81 | 82 | login(user); 83 | 84 | return ok(); 85 | } catch { 86 | return new Response('Unauthorized', { status: 401 }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /server/ai/embeddings.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | 3 | import { Story } from '@nokkio/magic'; 4 | 5 | import { openai, pinecone } from 'server/ai/clients.ts'; 6 | 7 | // TODO: Support .env for endpoints so we don't have to do stuff like this 8 | const PINECONE_INDEX_NAMESPACE = 9 | process.env.NODE_ENV === 'production' ? 'bedtime-ai' : 'bedtime-ai-dev'; 10 | 11 | const pineconeIndex = pinecone.index<{ isPublic: boolean; userId: string }>( 12 | 'nokkio-test', 13 | ); 14 | 15 | async function getEmbeddingsForStory(story: Story): Promise> { 16 | if (story.text === null) { 17 | throw new Error( 18 | 'Cannot generate embeddings when Story does not have text.', 19 | ); 20 | } 21 | 22 | const result = await openai.embeddings.create({ 23 | input: story.text, 24 | model: 'text-embedding-3-small', 25 | dimensions: 512, 26 | }); 27 | 28 | return result.data[0].embedding; 29 | } 30 | 31 | export async function batchEmbeddingsForStories( 32 | stories: Array, 33 | ): Promise { 34 | const batch: Array<{ record: Story; vector: Array }> = []; 35 | 36 | for (const story of stories) { 37 | const vector = await getEmbeddingsForStory(story); 38 | batch.push({ record: story, vector }); 39 | } 40 | 41 | await storeVectorsForStories(batch); 42 | } 43 | 44 | export async function updateEmbeddingForStory(story: Story): Promise { 45 | const vector = await getEmbeddingsForStory(story); 46 | 47 | await storeVectorsForStories([{ record: story, vector }]); 48 | } 49 | 50 | async function storeVectorsForStories( 51 | stories: Array<{ record: Story; vector: Array }>, 52 | ): Promise { 53 | await pineconeIndex.namespace(PINECONE_INDEX_NAMESPACE).upsert( 54 | stories.map(({ record, vector }) => ({ 55 | id: record.id, 56 | values: vector, 57 | metadata: { isPublic: record.isPublic, userId: record.userId }, 58 | })), 59 | ); 60 | } 61 | 62 | export async function findSimilarStories(story: Story): Promise> { 63 | const results = await pineconeIndex 64 | .namespace(PINECONE_INDEX_NAMESPACE) 65 | .query({ 66 | id: story.id, 67 | topK: 5, 68 | filter: { $or: [{ isPublic: true }, { userId: story.userId }] }, 69 | }); 70 | 71 | const ids = results.matches.map((m) => m.id).filter((id) => id !== story.id); 72 | 73 | const stories = await Story.find({ 74 | filter: { 75 | id: ids, 76 | }, 77 | }); 78 | 79 | const final: Array = []; 80 | 81 | stories.forEach((s) => { 82 | final[ids.indexOf(s.id)] = s; 83 | }); 84 | 85 | return final; 86 | } 87 | -------------------------------------------------------------------------------- /pages/admin/index.tsx: -------------------------------------------------------------------------------- 1 | import { usePageData, Link } from '@nokkio/router'; 2 | import type { PageMetadataFunction, PageDataArgs } from '@nokkio/router'; 3 | import { Story, User } from '@nokkio/magic'; 4 | 5 | export async function getPageData({ auth }: PageDataArgs) { 6 | if (auth === null || !auth.isAdmin) { 7 | return null; 8 | } 9 | 10 | return Story.find({ 11 | sort: '-createdAt', 12 | with: ['user'], 13 | }); 14 | } 15 | 16 | export const getPageMetadata: PageMetadataFunction = ({ 17 | pageData, 18 | }) => { 19 | if (pageData === null) { 20 | return { http: { status: 404 } }; 21 | } 22 | 23 | return { title: "Tonight's Bedtime Story: Admin" }; 24 | }; 25 | 26 | function createDeleteHandler(story: Story) { 27 | return () => { 28 | if (confirm(`Are you sure you want to delete ${story.title}?`)) { 29 | story.delete(); 30 | } 31 | }; 32 | } 33 | 34 | function createBanHandler(user: User) { 35 | return () => { 36 | if (confirm(`Are you sure you want to ban ${user.email}?`)) { 37 | user.update({ isBanned: true }); 38 | } 39 | }; 40 | } 41 | 42 | function createUnbanHandler(user: User) { 43 | return () => { 44 | if (confirm(`Are you sure you want to unban ${user.email}?`)) { 45 | user.update({ isBanned: false }); 46 | } 47 | }; 48 | } 49 | 50 | export default function AdminPage() { 51 | const pageData = usePageData(); 52 | 53 | if (pageData === null) { 54 | return null; 55 | } 56 | 57 | return ( 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | {pageData.map((story) => ( 68 | 69 | 70 | 71 | 84 | 85 | ))} 86 | 87 |
UserPromptActions
{story.user.email}{story.prompt} 72 | 73 | View 74 | 75 | 76 | {story.user.isBanned ? ( 77 | 80 | ) : ( 81 | 82 | )} 83 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /pages/stories/[id]/processing.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import type { PageMetadataFunction } from '@nokkio/router'; 4 | import { useStory } from '@nokkio/magic'; 5 | import { useNavigate, Link } from '@nokkio/router'; 6 | 7 | import Progress from 'components/Progress'; 8 | 9 | export const getPageMetadata: PageMetadataFunction = () => { 10 | return { title: "Tonight's Bedtime Story: Creating..." }; 11 | }; 12 | 13 | type PageParams = { id: string }; 14 | 15 | function InProgress({ 16 | children, 17 | estimatedTime, 18 | }: { 19 | children: string; 20 | estimatedTime: number; 21 | }) { 22 | return ( 23 |
24 | 25 |

{children}

26 |
27 | ); 28 | } 29 | 30 | function Done({ children }: { children: React.ReactNode }) { 31 | return ( 32 |
33 | 41 | 46 | 47 |

{children}

48 |
49 | ); 50 | } 51 | 52 | export default function ({ params }: { params: PageParams }) { 53 | const story = useStory(params.id, { live: true }); 54 | const navigate = useNavigate(); 55 | 56 | useEffect(() => { 57 | if (story.state === 'ready') { 58 | navigate(`/stories/${story.id}`, true); 59 | } 60 | }, [story.state]); 61 | 62 | if (story.state === 'ready') { 63 | // This will redirect from the effect above, so just render nothing 64 | // to prevent flashing content 65 | return null; 66 | } 67 | 68 | if (story.state === 'failed') { 69 | return ( 70 | <> 71 |

Uh oh.

72 |
73 |

74 | Your story failed to create, sorry. Please{' '} 75 | 76 | go back to the home page 77 | {' '} 78 | and try again. 79 |

80 |
81 | 82 | ); 83 | } 84 | 85 | return ( 86 | <> 87 |

88 | Tonight's Bedtime Story 89 |

90 |
91 | {story.title === null && ( 92 | 93 | Generating a story from your prompt... 94 | 95 | )} 96 | {story.title !== null && Created "{story.title}"} 97 | {story.title !== null && ( 98 | 99 | Generating image and audio... 100 | 101 | )} 102 |
103 | 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /pages/stories/create.tsx: -------------------------------------------------------------------------------- 1 | import { useState, ChangeEvent, KeyboardEventHandler, type JSX } from 'react'; 2 | 3 | import type { PageMetadataFunction } from '@nokkio/router'; 4 | import { usePageData } from '@nokkio/router'; 5 | import { Story } from '@nokkio/magic'; 6 | import { useForm, Textarea } from '@nokkio/forms'; 7 | import { useAuth } from '@nokkio/auth'; 8 | import { Img } from '@nokkio/image'; 9 | 10 | import SignInWithGoogleButton from 'components/SignInWithGoogleButton'; 11 | 12 | export const getPageMetadata: PageMetadataFunction = () => { 13 | return { title: "Tonight's Bedtime Story: Create a story" }; 14 | }; 15 | 16 | export async function getPageData() { 17 | const params = new URLSearchParams(location.search); 18 | const from = params.get('from'); 19 | 20 | if (from) { 21 | return { 22 | basedOn: await Story.findById(from), 23 | }; 24 | } 25 | 26 | return { basedOn: null }; 27 | } 28 | 29 | const listenForEnter: KeyboardEventHandler = (e) => { 30 | if (e.key === 'Enter') { 31 | e.preventDefault(); 32 | e.currentTarget.form?.requestSubmit(); 33 | } 34 | }; 35 | 36 | export default function Index(): JSX.Element { 37 | const { basedOn } = usePageData(); 38 | const { logout, isAuthenticated, user } = useAuth(); 39 | const [isSubmittable, setIsSubmittable] = useState(false); 40 | const { Form, isProcessing } = useForm(Story, { 41 | initialValues: 42 | basedOn !== null 43 | ? { 44 | parentStoryId: basedOn.id, 45 | } 46 | : undefined, 47 | redirectOnSuccess: (story) => { 48 | return `/stories/${story.id}/processing`; 49 | }, 50 | }); 51 | 52 | function handleChange(e: ChangeEvent) { 53 | setIsSubmittable(e.currentTarget.value.trim().length > 0); 54 | } 55 | 56 | if (!isAuthenticated) { 57 | return ( 58 |
59 |
60 |
Login to continue
61 |

Authenticate with Google to create your first story.

62 |
63 | 64 |
65 |
66 |
67 | ); 68 | } 69 | 70 | if (user.isBanned) { 71 | return ( 72 |
73 |
74 |
Not authorized
75 |

You are no longer allowed to create stories.

76 |
77 |
78 | ); 79 | } 80 | 81 | return ( 82 | <> 83 |

logout()} 85 | className="text-2xl lg:text-6xl font-bold px-6 lg:px-12" 86 | > 87 | Tonight's Bedtime Story 88 |

89 | {basedOn !== null && ( 90 |
91 |
92 | 93 |
94 |
95 |
96 | Creating a new story based on 97 |
98 |
{basedOn.title}
99 |
100 | Enter the changes you'd like to make below, and a new version will 101 | be created. 102 |
103 |
104 |
105 | )} 106 | 107 |
108 |