├── .dev.vars.example ├── .github └── workflows │ └── _studio.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── t:cache.code-snippets ├── t:client.code-snippets ├── t:hours.code-snippets ├── t:kwhclient.code-snippets ├── t:response.code-snippets └── t:server.code-snippets ├── README.md ├── astro.config.ts ├── db └── config.ts ├── package.json ├── pnpm-lock.yaml ├── public └── favicon.svg ├── src ├── actions.ts ├── auth.ts ├── components │ ├── Boards.tsx │ └── Form.tsx ├── env.d.ts ├── icons │ ├── github.svg │ └── logo.svg ├── layouts │ └── Layout.astro ├── middleware │ ├── co2.ts │ ├── index.ts │ └── user.ts ├── pages │ ├── audio │ │ └── [key].ts │ ├── index.astro │ └── login │ │ ├── github │ │ ├── callback.ts │ │ └── index.ts │ │ └── index.astro └── utils │ └── index.ts ├── tailwind.config.mjs ├── tsconfig.json └── wrangler.toml /.dev.vars.example: -------------------------------------------------------------------------------- 1 | ASTRO_STUDIO_APP_TOKEN= 2 | GITHUB_ID= 3 | GITHUB_SECRET= -------------------------------------------------------------------------------- /.github/workflows/_studio.yml: -------------------------------------------------------------------------------- 1 | name: Astro Studio 2 | 3 | env: 4 | ASTRO_STUDIO_APP_TOKEN: ${{secrets.ASTRO_STUDIO_APP_TOKEN }} 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | types: [opened, reopened, synchronize] 12 | 13 | jobs: 14 | DB: 15 | permissions: 16 | contents: read 17 | actions: read 18 | pull-requests: write 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | - uses: jaid/action-npm-install@v1.2.1 26 | - uses: withastro/action-studio@main 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | # cloudflare 24 | .wrangler/ 25 | worker-configuration.d.ts 26 | .dev.vars 27 | .dev.vars.* 28 | !.dev.vars.example -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/t:cache.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "t:cache": { 3 | "scope": "", 4 | "prefix": "t:cache", 5 | "body": [ 6 | " headers.set(\"Cache-Control\", \"public, max-age=31536000, immutable\");" 7 | ], 8 | "description": "" 9 | } 10 | } -------------------------------------------------------------------------------- /.vscode/t:client.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "t:client": { 3 | "scope": "", 4 | "prefix": "t:client", 5 | "body": [ 6 | " const kWh = (bytes / Math.pow(10, 12)) * userCO2_per_GB;\n const co2 = kWh * awsCO2_per_kWh;\n return co2;" 7 | ], 8 | "description": "" 9 | } 10 | } -------------------------------------------------------------------------------- /.vscode/t:hours.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "t:hours": { 3 | "scope": "", 4 | "prefix": "t:hours", 5 | "body": [ 6 | " const hours = time / 1000 / 3600;" 7 | ], 8 | "description": "" 9 | } 10 | } -------------------------------------------------------------------------------- /.vscode/t:kwhclient.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "t:kwhclient": { 3 | "scope": "", 4 | "prefix": "t:kwhclient", 5 | "body": [ 6 | " const kWh = (bytes / Math.pow(10, 12)) * userCO2_per_GB;" 7 | ], 8 | "description": "" 9 | } 10 | } -------------------------------------------------------------------------------- /.vscode/t:response.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "t:response": { 3 | "scope": "", 4 | "prefix": "t:response", 5 | "body": [ 6 | " return new Response(\"File too large\", { status: 413 });" 7 | ], 8 | "description": "" 9 | } 10 | } -------------------------------------------------------------------------------- /.vscode/t:server.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "t:server": { 3 | "scope": "", 4 | "prefix": "t:server", 5 | "body": [ 6 | " const time = performance.now() - start;\n const hours = time / 1000 / 3600;\n const kWh = hours * awskW;\n const co2 = kWh * awsCO2_per_kWh;\n return co2;" 7 | ], 8 | "description": "" 9 | } 10 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Astro Starter Kit: Minimal 2 | 3 | ```sh 4 | npm create astro@latest -- --template minimal 5 | ``` 6 | 7 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal) 8 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal) 9 | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json) 10 | 11 | > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! 12 | 13 | ## 🚀 Project Structure 14 | 15 | Inside of your Astro project, you'll see the following folders and files: 16 | 17 | ```text 18 | / 19 | ├── public/ 20 | ├── src/ 21 | │ └── pages/ 22 | │ └── index.astro 23 | └── package.json 24 | ``` 25 | 26 | Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. 27 | 28 | There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components. 29 | 30 | Any static assets, like images, can be placed in the `public/` directory. 31 | 32 | ## 🧞 Commands 33 | 34 | All commands are run from the root of the project, from a terminal: 35 | 36 | | Command | Action | 37 | | :------------------------ | :----------------------------------------------- | 38 | | `npm install` | Installs dependencies | 39 | | `npm run dev` | Starts local dev server at `localhost:4321` | 40 | | `npm run build` | Build your production site to `./dist/` | 41 | | `npm run preview` | Preview your build locally, before deploying | 42 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | 43 | | `npm run astro -- --help` | Get help using the Astro CLI | 44 | 45 | ## 👀 Want to learn more? 46 | 47 | Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). 48 | -------------------------------------------------------------------------------- /astro.config.ts: -------------------------------------------------------------------------------- 1 | import cloudflare from "@astrojs/cloudflare"; 2 | import tailwind from "@astrojs/tailwind"; 3 | import simpleStackForm from "simple-stack-form"; 4 | import react from "@astrojs/react"; 5 | import { defineConfig } from "astro/config"; 6 | import db from "@astrojs/db"; 7 | 8 | import icon from "astro-icon"; 9 | 10 | // https://astro.build/config 11 | export default defineConfig({ 12 | output: "hybrid", 13 | adapter: cloudflare({ 14 | platformProxy: { 15 | enabled: true, 16 | }, 17 | }), 18 | integrations: [tailwind(), simpleStackForm(), react(), db(), icon()], 19 | vite: { 20 | optimizeDeps: { 21 | exclude: ["astro:db", "oslo"], 22 | }, 23 | }, 24 | experimental: { 25 | actions: true, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /db/config.ts: -------------------------------------------------------------------------------- 1 | import { column, defineDb, defineTable } from "astro:db"; 2 | 3 | const User = defineTable({ 4 | columns: { 5 | id: column.text({ 6 | primaryKey: true, 7 | }), 8 | github_id: column.text({ unique: true }), 9 | username: column.text(), 10 | }, 11 | }); 12 | 13 | const Session = defineTable({ 14 | columns: { 15 | id: column.text({ 16 | primaryKey: true, 17 | }), 18 | expiresAt: column.date(), 19 | userId: column.text({ 20 | references: () => User.columns.id, 21 | }), 22 | }, 23 | }); 24 | 25 | const Sound = defineTable({ 26 | columns: { 27 | id: column.number({ primaryKey: true }), 28 | boardId: column.number({ 29 | references: () => Board.columns.id, 30 | }), 31 | emojiId: column.text(), 32 | emojiSkin: column.number({ 33 | optional: true, 34 | }), 35 | name: column.text({ 36 | optional: true, 37 | }), 38 | audioFileKey: column.text({ 39 | unique: true, 40 | }), 41 | audioFileName: column.text(), 42 | }, 43 | }); 44 | 45 | const Board = defineTable({ 46 | columns: { 47 | id: column.number({ primaryKey: true }), 48 | userId: column.text({ 49 | references: () => User.columns.id, 50 | }), 51 | name: column.text(), 52 | }, 53 | }); 54 | 55 | const CO2 = defineTable({ 56 | columns: { 57 | route: column.text({ primaryKey: true }), 58 | referer: column.text(), 59 | server: column.number(), 60 | client: column.number(), 61 | }, 62 | }); 63 | 64 | // https://astro.build/db/config 65 | export default defineDb({ 66 | tables: { Sound, Board, User, Session, CO2 }, 67 | }); 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "womp", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "wrangler types && astro dev", 7 | "start": "wrangler types && astro dev", 8 | "build": "wrangler types && astro check && astro build --remote", 9 | "preview": "wrangler types && astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@astrojs/check": "^0.7.0", 14 | "@astrojs/cloudflare": "^10.4.0", 15 | "@astrojs/db": "^0.11.5", 16 | "@astrojs/react": "^3.5.0", 17 | "@astrojs/tailwind": "^5.1.0", 18 | "@emoji-mart/data": "^1.1.2", 19 | "@emoji-mart/react": "^1.1.1", 20 | "@types/react": "^18.2.45", 21 | "@types/react-dom": "^18.2.18", 22 | "arctic": "^1.5.0", 23 | "astro": "^4.10.2", 24 | "astro-icon": "^1.1.0", 25 | "emoji-mart": "^5.5.2", 26 | "lucia": "^3.1.1", 27 | "lucia-adapter-astrodb": "^0.0.7", 28 | "nanoid": "^5.0.4", 29 | "oslo": "^1.2.0", 30 | "react": "^18.2.0", 31 | "react-aria-components": "^1.0.0", 32 | "react-dom": "^18.2.0", 33 | "simple-stack-form": "^0.1.5", 34 | "tailwind-merge": "^2.2.0", 35 | "tailwindcss": "^3.4.0", 36 | "typescript": "^5.3.3", 37 | "wrangler": "^3.47.1", 38 | "zod": "^3.22.4" 39 | }, 40 | "devDependencies": { 41 | "@cloudflare/workers-types": "^4.20240404.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | import { defineAction, z } from "astro:actions"; 2 | import { CO2, db, eq } from "astro:db"; 3 | 4 | type Result = Array<{ 5 | client: number; 6 | server: number; 7 | route: string; 8 | referer: string; 9 | }>; 10 | 11 | export const server = { 12 | getCo2: defineAction({ 13 | input: z.object({ 14 | referer: z.string(), 15 | }), 16 | handler: async ({ referer }): Promise => { 17 | const metrics = await db 18 | .select({ 19 | client: CO2.client, 20 | server: CO2.server, 21 | route: CO2.route, 22 | }) 23 | .from(CO2) 24 | .where(eq(CO2.referer, referer)); 25 | return metrics; 26 | }, 27 | }), 28 | }; 29 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | // src/auth.ts 2 | import { Lucia } from "lucia"; 3 | import { AstroDBAdapter } from "lucia-adapter-astrodb"; 4 | import { db, Session, User } from "astro:db"; 5 | import { GitHub } from "arctic"; 6 | 7 | export const github = (env: { GITHUB_ID: string; GITHUB_SECRET: string }) => 8 | new GitHub(env.GITHUB_ID, env.GITHUB_SECRET); 9 | 10 | const adapter = new AstroDBAdapter(db, Session, User); 11 | 12 | export const lucia = new Lucia(adapter, { 13 | sessionCookie: { 14 | attributes: { 15 | // set to `true` when using HTTPS 16 | secure: import.meta.env.PROD, 17 | }, 18 | }, 19 | getUserAttributes: (attributes) => { 20 | return { 21 | // attributes has the type of DatabaseUserAttributes 22 | githubId: attributes.github_id, 23 | username: attributes.username, 24 | }; 25 | }, 26 | }); 27 | 28 | declare module "lucia" { 29 | interface Register { 30 | Lucia: typeof lucia; 31 | DatabaseUserAttributes: DatabaseUserAttributes; 32 | } 33 | } 34 | 35 | interface DatabaseUserAttributes { 36 | github_id: number; 37 | username: string; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/Boards.tsx: -------------------------------------------------------------------------------- 1 | import { createForm } from "simple:form"; 2 | import { 3 | FileDropSubmit, 4 | FileTriggerSubmit, 5 | Form, 6 | useFormContext, 7 | useSubmit, 8 | } from "./Form"; 9 | import { z } from "zod"; 10 | import { useRef, useState } from "react"; 11 | import { Button, Popover } from "react-aria-components"; 12 | import data, { type EmojiMartData } from "@emoji-mart/data"; 13 | import EmojiPickerMod from "@emoji-mart/react"; 14 | 15 | // I hate ESM... 16 | const EmojiPicker = import.meta.env.SSR 17 | ? // @ts-expect-error 18 | EmojiPickerMod.default 19 | : EmojiPickerMod; 20 | 21 | const audioFileValidator = z 22 | .instanceof(File) 23 | .refine((file) => file.type.startsWith("audio/")); 24 | 25 | export const newSound = createForm({ 26 | audioFiles: z.array(audioFileValidator), 27 | }); 28 | 29 | export const editEmoji = createForm({ 30 | id: z.number(), 31 | emojiId: z.string(), 32 | emojiSkin: z.number().optional(), 33 | }); 34 | 35 | export const editFile = createForm({ 36 | id: z.number(), 37 | audioFile: audioFileValidator, 38 | }); 39 | 40 | function DocumentArrowDown() { 41 | audioFileValidator.nullable().parse(null); 42 | return ( 43 | 51 | 56 | 57 | ); 58 | } 59 | 60 | export function NewSoundDropZone() { 61 | return ( 62 |
68 | 73 | 74 | 75 | Drag sounds here 76 | 77 | 80 | 81 | 82 | 83 |
84 | ); 85 | } 86 | 87 | type EmojiSelection = { id: string; skin: number | undefined }; 88 | 89 | function EmojiDropdown(selection: EmojiSelection) { 90 | const [emojiData, setEmojiData] = useState(selection); 91 | const emoji = (data as EmojiMartData).emojis[emojiData.id]?.skins[ 92 | emojiData.skin ?? 0 93 | ]?.native; 94 | 95 | const [isOpen, setIsOpen] = useState(false); 96 | const triggerRef = useRef(null); 97 | const formContext = useFormContext(); 98 | const submit = useSubmit(formContext); 99 | 100 | return ( 101 | <> 102 | 111 | 112 | 113 | { 116 | const formData = new FormData( 117 | formContext.formRef?.current ?? undefined 118 | ); 119 | formData.set("emojiId", s.id); 120 | formData.set("emojiSkin", s.skin?.toString() ?? ""); 121 | submit(formData); 122 | setIsOpen(false); 123 | return setEmojiData(s); 124 | }} 125 | /> 126 | 127 | 128 | 129 | 130 | ); 131 | } 132 | 133 | function ChevronDown() { 134 | return ( 135 | 143 | 148 | 149 | ); 150 | } 151 | 152 | export function EditCard({ 153 | id, 154 | emojiId, 155 | emojiSkin, 156 | audioFileKey, 157 | audioFileName, 158 | }: { 159 | id: number; 160 | emojiId: string; 161 | emojiSkin: number | undefined; 162 | audioFileKey: string; 163 | audioFileName: string; 164 | }) { 165 | let audioRef = useRef(null); 166 | return ( 167 |
168 |
169 | 170 | 171 | 172 |
173 | 174 | 178 |
179 | 194 | 195 | 198 | 199 |
200 |
201 |
202 |
203 | ); 204 | } 205 | 206 | function PlayIcon() { 207 | return ( 208 | 214 | 219 | 220 | ); 221 | } 222 | -------------------------------------------------------------------------------- /src/components/Form.tsx: -------------------------------------------------------------------------------- 1 | // Generated by simple:form 2 | 3 | import { navigate } from "astro:transitions/client"; 4 | import { 5 | type ComponentProps, 6 | createContext, 7 | useContext, 8 | useState, 9 | useRef, 10 | type FormEvent, 11 | } from "react"; 12 | import { 13 | DropZone, 14 | FileTrigger, 15 | type DropZoneProps, 16 | type FileDropItem, 17 | type FileTriggerProps, 18 | } from "react-aria-components"; 19 | import { 20 | type FieldErrors, 21 | type FormState, 22 | type FormValidator, 23 | getInitialFormState, 24 | toSetValidationErrors, 25 | toTrackAstroSubmitStatus, 26 | toValidateField, 27 | validateForm, 28 | formNameInputProps, 29 | } from "simple:form"; 30 | 31 | export function useCreateFormContext( 32 | validator: FormValidator, 33 | fieldErrors?: FieldErrors, 34 | formRef?: React.MutableRefObject 35 | ) { 36 | const initial = getInitialFormState({ validator, fieldErrors }); 37 | const [formState, setFormState] = useState(initial); 38 | return { 39 | value: formState, 40 | set: setFormState, 41 | formRef, 42 | validator, 43 | setValidationErrors: toSetValidationErrors(setFormState), 44 | validateField: toValidateField(setFormState), 45 | trackAstroSubmitStatus: toTrackAstroSubmitStatus(setFormState), 46 | }; 47 | } 48 | 49 | export function useFormContext() { 50 | const formContext = useContext(FormContext); 51 | if (!formContext) { 52 | throw new Error( 53 | "Form context not found. `useFormContext()` should only be called from children of a
component." 54 | ); 55 | } 56 | return formContext; 57 | } 58 | 59 | type FormContextType = ReturnType; 60 | 61 | const FormContext = createContext(undefined); 62 | 63 | export function useSubmit(formContext: FormContextType) { 64 | const handleSubmit = async (formData: FormData, e?: FormEvent) => { 65 | e?.preventDefault(); 66 | e?.stopPropagation(); 67 | 68 | formContext.set((formState) => ({ 69 | ...formState, 70 | isSubmitPending: true, 71 | submitStatus: "validating", 72 | })); 73 | 74 | const parsed = await validateForm({ 75 | formData, 76 | validator: formContext.validator, 77 | }); 78 | if (parsed.data) { 79 | navigate(window.location.href, { formData }); 80 | return formContext.trackAstroSubmitStatus(); 81 | } 82 | 83 | formContext.setValidationErrors(parsed.fieldErrors); 84 | }; 85 | 86 | return handleSubmit; 87 | } 88 | 89 | export function Form({ 90 | children, 91 | validator, 92 | context, 93 | fieldErrors, 94 | name, 95 | ...formProps 96 | }: { 97 | validator: FormValidator; 98 | context?: FormContextType; 99 | fieldErrors?: FieldErrors; 100 | } & Omit, "method" | "onSubmit">) { 101 | const formRef = useRef(null); 102 | const formContext = 103 | context ?? useCreateFormContext(validator, fieldErrors, formRef); 104 | const submit = useSubmit(formContext); 105 | 106 | return ( 107 | 108 | submit(new FormData(e.currentTarget), e)} 113 | > 114 | {name ? : null} 115 | {children} 116 | 117 | 118 | ); 119 | } 120 | 121 | export function FileDropSubmit({ 122 | name, 123 | allowsMultiple, 124 | ...dropZoneProps 125 | }: DropZoneProps & { 126 | name: string; 127 | allowsMultiple?: boolean; 128 | }) { 129 | const formContext = useFormContext(); 130 | const submit = useSubmit(formContext); 131 | const fieldState = formContext.value.fields[name]; 132 | if (!fieldState) { 133 | throw new Error( 134 | `Input "${name}" not found in form. Did you use the
component?` 135 | ); 136 | } 137 | 138 | return ( 139 | 141 | types.has("audio/mpeg") ? "copy" : "cancel" 142 | } 143 | onDrop={async (e) => { 144 | const items = e.items.filter((file) => file.kind === "file") as 145 | | FileDropItem[]; 146 | 147 | const formData = new FormData( 148 | formContext.formRef?.current ?? undefined 149 | ); 150 | 151 | if (allowsMultiple) { 152 | for (const item of items) { 153 | formData.append(name, await item.getFile()); 154 | } 155 | } else { 156 | const file = await items[0]?.getFile(); 157 | file && formData.set(name, file); 158 | } 159 | 160 | submit(formData); 161 | }} 162 | {...dropZoneProps} 163 | /> 164 | ); 165 | } 166 | 167 | export function FileTriggerSubmit({ 168 | name, 169 | ...fileTriggerProps 170 | }: { 171 | name: string; 172 | } & FileTriggerProps) { 173 | const formContext = useFormContext(); 174 | const submit = useSubmit(formContext); 175 | return ( 176 | { 178 | const files = e ? Array.from(e) : []; 179 | const formData = new FormData( 180 | formContext.formRef?.current ?? undefined 181 | ); 182 | 183 | if (fileTriggerProps.allowsMultiple) { 184 | for (const file of files) { 185 | formData.append(name, file); 186 | } 187 | } else { 188 | const file = files[0]; 189 | file && formData.set(name, file); 190 | } 191 | 192 | submit(formData); 193 | }} 194 | {...fileTriggerProps} 195 | /> 196 | ); 197 | } 198 | 199 | export function Input(inputProps: ComponentProps<"input"> & { name: string }) { 200 | const formContext = useFormContext(); 201 | const fieldState = formContext.value.fields[inputProps.name]; 202 | if (!fieldState) { 203 | throw new Error( 204 | `Input "${inputProps.name}" not found in form. Did you use the component?` 205 | ); 206 | } 207 | 208 | const { hasErroredOnce, validationErrors, validator } = fieldState; 209 | return ( 210 | <> 211 | { 213 | const value = e.target.value; 214 | if (value === "") return; 215 | formContext.validateField(inputProps.name, value, validator); 216 | }} 217 | onChange={async (e) => { 218 | if (!hasErroredOnce) return; 219 | const value = e.target.value; 220 | formContext.validateField(inputProps.name, value, validator); 221 | }} 222 | {...inputProps} 223 | /> 224 | {validationErrors?.map((e) => ( 225 |

{e}

226 | ))} 227 | 228 | ); 229 | } 230 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | /// 6 | /// 7 | 8 | interface CfEnv extends Env { 9 | GITHUB_ID: string; 10 | GITHUB_SECRET: string; 11 | } 12 | 13 | type Runtime = import("@astrojs/cloudflare").Runtime; 14 | 15 | declare namespace App { 16 | interface Locals extends Runtime { 17 | session: import("lucia").Session | null; 18 | user: import("lucia").User | null; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/icons/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 11 | 14 | -------------------------------------------------------------------------------- /src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | type Props = { 3 | title?: string; 4 | }; 5 | 6 | const { title = "WOMP" } = Astro.props; 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | {title} 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/middleware/co2.ts: -------------------------------------------------------------------------------- 1 | import { CO2, db, eq } from "astro:db"; 2 | import { defineMiddleware } from "astro:middleware"; 3 | 4 | // Source: memory-intensive load for c5 processor 5 | // https://medium.com/teads-engineering/estimating-aws-ec2-instances-power-consumption-c9745e347959 6 | const awsTotalMachineWatts = 174; 7 | // Fudge factor since we're not using the full machine 8 | const awsUtilization = 0.1; 9 | const awskW = (awsTotalMachineWatts * awsUtilization) / 1000; 10 | // Source: cloud carbon footprint 11 | // https://github.com/cloud-carbon-footprint/cloud-carbon-footprint/blob/e48c659f6dafc8b783e570053024f28b88aafc79/microsite/docs/Methodology.md#aws-2 12 | const awsCO2g_per_kWh = 0.000415755 * Math.pow(10, 6); 13 | 14 | // Source: global averages per Sustainable Web Design model 15 | // https://sustainablewebdesign.org/estimating-digital-emissions/#faq 16 | const userOperationalkWh_per_GB = 17 | (421 * Math.pow(10, 9)) / (5.29 * Math.pow(10, 12)); 18 | const userEmbodiedkWh_per_GB = 19 | (430 * Math.pow(10, 9)) / (5.29 * Math.pow(10, 12)); 20 | const userkWh_per_GB = userOperationalkWh_per_GB + userEmbodiedkWh_per_GB; 21 | const userCO2g_per_kWh = 494; 22 | 23 | export const co2 = defineMiddleware(async (context, next) => { 24 | if (context.url.pathname.endsWith("_actions/getCo2")) return next(); 25 | 26 | const referer = context.request.headers.get("Referer"); 27 | // If no referer, we don't have a key to track for co2 analytics 28 | if (!referer) return next(); 29 | 30 | const start = performance.now(); 31 | const response = await next(); 32 | 33 | if (context.url.href === referer) { 34 | await db.delete(CO2).where(eq(CO2.referer, referer)); 35 | } 36 | if (!response.body) { 37 | await db 38 | .insert(CO2) 39 | .values({ 40 | route: context.url.pathname, 41 | referer, 42 | client: 0, 43 | server: getServerCO2(start), 44 | }) 45 | .onConflictDoUpdate({ 46 | target: CO2.route, 47 | set: { 48 | referer, 49 | client: 0, 50 | server: getServerCO2(start), 51 | }, 52 | }); 53 | return response; 54 | } 55 | 56 | async function* render() { 57 | let clientBytes = 0; 58 | for await (const chunk of response.body as ReadableStream) { 59 | clientBytes += chunk.byteLength; 60 | yield chunk; 61 | } 62 | await db 63 | .insert(CO2) 64 | .values({ 65 | route: context.url.pathname, 66 | referer, 67 | client: getClientCO2(clientBytes), 68 | server: getServerCO2(start), 69 | }) 70 | .onConflictDoUpdate({ 71 | target: CO2.route, 72 | set: { 73 | referer, 74 | client: getClientCO2(clientBytes), 75 | server: getServerCO2(start), 76 | }, 77 | }); 78 | } 79 | 80 | // @ts-expect-error generator not assignable to ReadableStream 81 | return new Response(render(), { headers: response.headers }); 82 | }); 83 | 84 | function getServerCO2(start: number) { 85 | const time = performance.now() - start; 86 | const hours = time / 1000 / 3600; 87 | const kWh = hours * awskW; 88 | const co2 = kWh * awsCO2g_per_kWh; 89 | return co2; 90 | } 91 | 92 | function getClientCO2(bytes: number) { 93 | const kWh = (bytes / Math.pow(10, 12)) * userkWh_per_GB; 94 | const co2 = kWh * userCO2g_per_kWh; 95 | return co2; 96 | } 97 | -------------------------------------------------------------------------------- /src/middleware/index.ts: -------------------------------------------------------------------------------- 1 | import { sequence } from "astro:middleware"; 2 | import { user } from "./user"; 3 | import { co2 } from "./co2"; 4 | 5 | export const onRequest = sequence(user, co2); 6 | -------------------------------------------------------------------------------- /src/middleware/user.ts: -------------------------------------------------------------------------------- 1 | import { lucia } from "../auth"; 2 | import { verifyRequestOrigin } from "lucia"; 3 | import { defineMiddleware } from "astro:middleware"; 4 | 5 | export const user = defineMiddleware(async (context, next) => { 6 | if (context.request.method !== "GET") { 7 | const originHeader = context.request.headers.get("Origin"); 8 | const hostHeader = context.request.headers.get("Host"); 9 | if ( 10 | !originHeader || 11 | !hostHeader || 12 | !verifyRequestOrigin(originHeader, [hostHeader]) 13 | ) { 14 | return new Response(null, { 15 | status: 403, 16 | }); 17 | } 18 | } 19 | 20 | const sessionId = context.cookies.get(lucia.sessionCookieName)?.value ?? null; 21 | if (!sessionId) { 22 | context.locals.user = null; 23 | context.locals.session = null; 24 | return next(); 25 | } 26 | 27 | const { session, user } = await lucia.validateSession(sessionId); 28 | if (session?.fresh) { 29 | const sessionCookie = lucia.createSessionCookie(session.id); 30 | context.cookies.set( 31 | sessionCookie.name, 32 | sessionCookie.value, 33 | sessionCookie.attributes 34 | ); 35 | } 36 | if (!session) { 37 | const sessionCookie = lucia.createBlankSessionCookie(); 38 | context.cookies.set( 39 | sessionCookie.name, 40 | sessionCookie.value, 41 | sessionCookie.attributes 42 | ); 43 | } 44 | context.locals.session = session; 45 | context.locals.user = user; 46 | return next(); 47 | }); 48 | -------------------------------------------------------------------------------- /src/pages/audio/[key].ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | 3 | export const prerender = false; 4 | 5 | export const GET: APIRoute = async (ctx) => { 6 | const { key } = ctx.params; 7 | if (!key) return new Response(null, { status: 400 }); 8 | 9 | const { R2 } = ctx.locals.runtime.env; 10 | const obj = await R2.get(key); 11 | 12 | if (obj === null) { 13 | return new Response(`${key} not found`, { status: 404 }); 14 | } 15 | 16 | const headers = new Headers(Object.entries(obj.httpMetadata ?? {})); 17 | headers.set("etag", obj.httpEtag); 18 | 19 | headers.set("Cache-Control", "public, max-age=31536000, immutable"); 20 | 21 | return new Response(obj.body, { headers }); 22 | }; 23 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { 3 | newSound, 4 | editEmoji, 5 | editFile, 6 | NewSoundDropZone, 7 | EditCard, 8 | } from "../components/Boards"; 9 | import { ViewTransitions } from "astro:transitions"; 10 | import { db, eq, Sound } from "astro:db"; 11 | import { safeId } from "../utils"; 12 | import untypedData, { type EmojiMartData } from "@emoji-mart/data"; 13 | import { Board } from "astro:db"; 14 | 15 | export const prerender = false; 16 | 17 | const user = Astro.locals.user; 18 | if (!user) { 19 | return Astro.redirect("/login"); 20 | } 21 | 22 | let board = await db 23 | .select({ id: Board.id }) 24 | .from(Board) 25 | .where(eq(Board.userId, user.id)) 26 | .get(); 27 | 28 | if (!board) { 29 | board = await db 30 | .insert(Board) 31 | .values({ 32 | name: user.username, 33 | userId: user.id, 34 | }) 35 | .returning({ id: Board.id }) 36 | .get(); 37 | } 38 | 39 | const data = untypedData as EmojiMartData; 40 | 41 | const { R2 } = Astro.locals.runtime.env; 42 | const newSoundReq = await Astro.locals.form.getDataByName( 43 | "new-sound", 44 | newSound 45 | ); 46 | 47 | const editEmojiReq = await Astro.locals.form.getDataByName( 48 | "edit-emoji", 49 | editEmoji 50 | ); 51 | 52 | const editFileReq = await Astro.locals.form.getDataByName( 53 | "edit-file", 54 | editFile 55 | ); 56 | 57 | if (editEmojiReq?.data) { 58 | const { id, emojiId, emojiSkin = null } = editEmojiReq.data; 59 | const entry = await db 60 | .select({ key: Sound.audioFileKey }) 61 | .from(Sound) 62 | .where(eq(Sound.id, id)) 63 | .get(); 64 | if (!entry) { 65 | Astro.response.status = 404; 66 | Astro.response.statusText = `Sound ${id} not found`; 67 | } else { 68 | await db 69 | .update(Sound) 70 | .set({ 71 | emojiId, 72 | emojiSkin, 73 | }) 74 | .where(eq(Sound.id, id)); 75 | } 76 | } 77 | 78 | if (editFileReq?.data) { 79 | const { id, audioFile } = editFileReq.data; 80 | const entry = await db 81 | .select({ key: Sound.audioFileKey }) 82 | .from(Sound) 83 | .where(eq(Sound.id, id)) 84 | .get(); 85 | if (!entry) { 86 | Astro.response.status = 404; 87 | Astro.response.statusText = `Sound ${id} not found`; 88 | } else { 89 | const { key } = entry; 90 | const buffer = await audioFile.arrayBuffer(); 91 | const kb500 = 500 * 1024; 92 | if (buffer.byteLength > kb500) { 93 | return new Response("File too large", { status: 413 }); 94 | } 95 | await R2.put(key, buffer); 96 | await db 97 | .update(Sound) 98 | .set({ 99 | audioFileName: audioFile.name, 100 | }) 101 | .where(eq(Sound.id, id)); 102 | } 103 | } 104 | 105 | if (newSoundReq?.data) { 106 | const { audioFiles } = newSoundReq.data; 107 | 108 | for (const audioFile of audioFiles) { 109 | const key = `${safeId()}-${audioFile.name}`; 110 | 111 | const buffer = await audioFile.arrayBuffer(); 112 | const kb500 = 500 * 1024; 113 | if (buffer.byteLength > kb500) { 114 | return new Response("File too large", { status: 413 }); 115 | } 116 | await R2.put(key, buffer); 117 | 118 | const emojis = Object.values(data.emojis); 119 | const randomIdx = Math.floor(Math.random() * emojis.length); 120 | 121 | await db.insert(Sound).values({ 122 | boardId: board.id, 123 | emojiId: emojis[randomIdx]!.id, 124 | audioFileName: audioFile.name, 125 | audioFileKey: key, 126 | }); 127 | } 128 | } 129 | 130 | const sounds = await db 131 | .select({ 132 | id: Sound.id, 133 | emojiId: Sound.emojiId, 134 | emojiSkin: Sound.emojiSkin, 135 | audioFileName: Sound.audioFileName, 136 | audioFileKey: Sound.audioFileKey, 137 | }) 138 | .from(Sound); 139 | --- 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | WOMP 149 | 150 | 151 |
152 |

CO2 usage

153 | 154 |
155 |
156 |
157 |
158 |
161 | { 162 | sounds.map((s) => ( 163 | 171 | )) 172 | } 173 |
174 |
177 | 178 |
179 | 210 | 211 | 212 | -------------------------------------------------------------------------------- /src/pages/login/github/callback.ts: -------------------------------------------------------------------------------- 1 | // pages/login/github/callback.ts 2 | import { github, lucia } from "../../../auth"; 3 | import { OAuth2RequestError } from "arctic"; 4 | import { generateId } from "lucia"; 5 | import { db, User, eq, Board } from "astro:db"; 6 | import type { APIContext } from "astro"; 7 | 8 | export const prerender = false; 9 | 10 | export async function GET(context: APIContext): Promise { 11 | const code = context.url.searchParams.get("code"); 12 | const state = context.url.searchParams.get("state"); 13 | const storedState = context.cookies.get("github_oauth_state")?.value ?? null; 14 | if (!code || !state || !storedState || state !== storedState) { 15 | return new Response(null, { 16 | status: 400, 17 | }); 18 | } 19 | 20 | try { 21 | const tokens = await github( 22 | context.locals.runtime.env 23 | ).validateAuthorizationCode(code); 24 | const githubUserResponse = await fetch("https://api.github.com/user", { 25 | headers: { 26 | Authorization: `Bearer ${tokens.accessToken}`, 27 | "User-Agent": import.meta.env.PROD 28 | ? "astro-womp-prod" 29 | : "astro-womp-dev", 30 | }, 31 | }); 32 | const githubUser: GitHubUser = await githubUserResponse.json(); 33 | 34 | // Replace this with your own DB client. 35 | const existingUser = await db 36 | .select() 37 | .from(User) 38 | .where(eq(User.github_id, githubUser.id)) 39 | .get(); 40 | 41 | if (existingUser) { 42 | const session = await lucia.createSession(existingUser.id, {}); 43 | const sessionCookie = lucia.createSessionCookie(session.id); 44 | context.cookies.set( 45 | sessionCookie.name, 46 | sessionCookie.value, 47 | sessionCookie.attributes 48 | ); 49 | return context.redirect("/"); 50 | } 51 | 52 | const userId = generateId(15); 53 | 54 | await db.insert(User).values({ 55 | id: userId, 56 | github_id: githubUser.id, 57 | username: githubUser.login, 58 | }); 59 | 60 | await db.insert(Board).values({ 61 | name: githubUser.login, 62 | userId, 63 | }); 64 | 65 | const session = await lucia.createSession(userId, {}); 66 | const sessionCookie = lucia.createSessionCookie(session.id); 67 | context.cookies.set( 68 | sessionCookie.name, 69 | sessionCookie.value, 70 | sessionCookie.attributes 71 | ); 72 | return context.redirect("/"); 73 | } catch (e) { 74 | console.error("Auth error:", e); 75 | // the specific error message depends on the provider 76 | if (e instanceof OAuth2RequestError) { 77 | // invalid code 78 | return new Response(null, { 79 | status: 400, 80 | }); 81 | } 82 | return new Response(null, { 83 | status: 500, 84 | }); 85 | } 86 | } 87 | 88 | interface GitHubUser { 89 | id: string; 90 | login: string; 91 | } 92 | -------------------------------------------------------------------------------- /src/pages/login/github/index.ts: -------------------------------------------------------------------------------- 1 | // pages/login/github/index.ts 2 | import { generateState } from "arctic"; 3 | import { github } from "../../../auth"; 4 | 5 | import type { APIContext } from "astro"; 6 | 7 | export const prerender = false; 8 | 9 | export async function GET(context: APIContext): Promise { 10 | const state = generateState(); 11 | const url = await github(context.locals.runtime.env).createAuthorizationURL( 12 | state 13 | ); 14 | 15 | context.cookies.set("github_oauth_state", state, { 16 | path: "/", 17 | secure: true, 18 | httpOnly: true, 19 | maxAge: 60 * 10, 20 | sameSite: "lax", 21 | }); 22 | 23 | return context.redirect(url.toString()); 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/login/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "../../layouts/Layout.astro"; 3 | import { Icon } from "astro-icon/components"; 4 | --- 5 | 6 | 7 |
8 |
9 | 10 |

The Soundboard builder for the edge.

11 | Sign in with GitHub 15 | 16 | 17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet, urlAlphabet } from "nanoid"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export const cn = twMerge; 5 | 6 | export const safeId = customAlphabet(urlAlphabet, 10); 7 | -------------------------------------------------------------------------------- /tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "react" 6 | } 7 | } -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "womp" 2 | pages_build_output_dir = "./dist" 3 | compatibility_date = "2024-04-08" 4 | compatibility_flags = ["nodejs_compat"] 5 | 6 | [[r2_buckets]] 7 | binding = 'R2' 8 | bucket_name = 'womp-audio' 9 | --------------------------------------------------------------------------------