├── .firebaserc ├── .gitignore ├── .vscode ├── extensions.json └── launch.json ├── README.md ├── astro.config.mjs ├── firebase.json ├── package-lock.json ├── package.json ├── public └── favicon.svg ├── src ├── components │ ├── AudioPlayer.astro │ ├── AudioRecorder.tsx │ ├── Button.astro │ ├── Container.astro │ ├── Empty.astro │ ├── H1.astro │ ├── LinkCTA.astro │ ├── Logo.astro │ ├── RecordingPublishFailed.astro │ ├── RecordingPublishSuccess.astro │ └── UploadingRecording.astro ├── constants │ ├── cookies.ts │ └── firebase.ts ├── env.d.ts ├── layouts │ └── BaseLayout.astro ├── pages │ ├── api │ │ ├── auth │ │ │ └── signout.ts │ │ └── recording.ts │ ├── index.astro │ ├── record.astro │ ├── signin.astro │ └── signup.astro ├── scripts │ ├── authClientValidationRules.ts │ ├── firebase │ │ ├── init.ts │ │ └── initServer.ts │ └── simpleISOStringParser.ts └── stores │ └── audioRecording.ts ├── storage.rules ├── tailwind.config.cjs └── tsconfig.json /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "be-audible" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.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 | # firebase logs 15 | firebase-debug.log 16 | ui-debug.log 17 | 18 | 19 | # environment variables 20 | .env 21 | .env.production 22 | 23 | # macOS-specific files 24 | .DS_Store 25 | 26 | # code editor files 27 | .vscode -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Be Audible! 2 | 3 | ## 👏🏽 A fullstack Astro application 4 | 5 | > This application is part of the [Understanding Astro](https://github.com/understanding-astro/understanding-astro-book) book. 6 | 7 |
8 | 9 | ![Understanding Astro](https://i.imgur.com/TfaKFNR.png) 10 | 11 |
12 |
13 | 14 | All commands are run from the root of the project, from a terminal: 15 | 16 | | Command | Action | 17 | | :---------------- | :------------------------------------------- | 18 | | `npm install` | Installs dependencies | 19 | | `npm run dev` | Starts local dev server at `localhost:3000` | 20 | | `npm run build` | Build your production site to `./dist/` | 21 | | `npm run preview` | Preview your build locally, before deploying | 22 | 23 |
24 | 25 | ## 👀 Note 26 | 27 | - Complete application lives in the `master` branch i.e., `git checkout master` 28 | - Checkout to the `clean-slate` branch to code along or start afresh i.e., `git checkout clean-slate` 29 | - Building the application is covered in Chapter 8 of [the book](https://github.com/understanding-astro/understanding-astro-book) 30 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | 3 | import tailwind from "@astrojs/tailwind"; 4 | import react from "@astrojs/react"; 5 | 6 | // https://astro.build/config 7 | export default defineConfig({ 8 | output: "server", 9 | integrations: [tailwind(), react()], 10 | }); 11 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "storage": { 3 | "rules": "storage.rules" 4 | }, 5 | "emulators": { 6 | "auth": { 7 | "port": 9098 8 | }, 9 | "storage": { 10 | "port": 3005 11 | }, 12 | "ui": { 13 | "enabled": true, 14 | "port": 4001 15 | }, 16 | "singleProjectMode": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "full-stack-astro", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview", 10 | "astro": "astro", 11 | "emulators": "firebase emulators:start" 12 | }, 13 | "dependencies": { 14 | "@astrojs/react": "^2.2.0", 15 | "@astrojs/tailwind": "^3.1.3", 16 | "@nanostores/react": "^0.7.1", 17 | "@types/react": "^18.2.7", 18 | "@types/react-dom": "^18.2.4", 19 | "astro": "^2.5.5", 20 | "firebase": "^9.22.1", 21 | "firebase-admin": "^11.8.0", 22 | "nanoid": "^4.0.2", 23 | "nanostores": "^0.9.0", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0", 26 | "react-voice-recorder-player": "^1.1.0", 27 | "tailwindcss": "^3.3.2", 28 | "validator.tool": "^2.2.5" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/AudioPlayer.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { simpleISOStringParser } from "@scripts/simpleISOStringParser"; 3 | 4 | type Props = { 5 | url: string; 6 | timeCreated: string; 7 | }; 8 | 9 | const { url, timeCreated } = Astro.props; 10 | --- 11 | 12 |
15 |
16 |
17 | 18 |
19 | 20 |
21 |
Listen:
22 | 23 | Audibled at {simpleISOStringParser(timeCreated)} 26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /src/components/AudioRecorder.tsx: -------------------------------------------------------------------------------- 1 | import { VoiceRecorder } from "react-voice-recorder-player"; 2 | 3 | type Props = { 4 | cta?: string; 5 | }; 6 | 7 | export const Recorder = (props: Props) => { 8 | return ( 9 |
10 | { 12 | // 👀 upload recording 13 | }} 14 | /> 15 | 16 | {props.cta} 17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/Button.astro: -------------------------------------------------------------------------------- 1 | --- 2 | type Props = { 3 | id?: string; 4 | class?: string; 5 | }; 6 | 7 | const { id = "", class: classnames } = Astro.props; 8 | --- 9 | 10 | 19 | -------------------------------------------------------------------------------- /src/components/Container.astro: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /src/components/Empty.astro: -------------------------------------------------------------------------------- 1 |
2 | 🥺 3 |

This place is empty. No one's spoken — yet

4 |
5 | -------------------------------------------------------------------------------- /src/components/H1.astro: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | -------------------------------------------------------------------------------- /src/components/LinkCTA.astro: -------------------------------------------------------------------------------- 1 | --- 2 | type Props = { 3 | href: string; 4 | }; 5 | 6 | const { href } = Astro.props; 7 | --- 8 | 9 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/Logo.astro: -------------------------------------------------------------------------------- 1 | 5 | 13 | 16 | 17 | 18 | BeAudible. 19 | 20 | -------------------------------------------------------------------------------- /src/components/RecordingPublishFailed.astro: -------------------------------------------------------------------------------- 1 |
4 | 5 |
6 | -------------------------------------------------------------------------------- /src/components/RecordingPublishSuccess.astro: -------------------------------------------------------------------------------- 1 |
4 | 5 |
6 | -------------------------------------------------------------------------------- /src/components/UploadingRecording.astro: -------------------------------------------------------------------------------- 1 |
4 |
7 | 8 |
9 |
10 | -------------------------------------------------------------------------------- /src/constants/cookies.ts: -------------------------------------------------------------------------------- 1 | export const TOKEN = "X-Token"; 2 | -------------------------------------------------------------------------------- /src/constants/firebase.ts: -------------------------------------------------------------------------------- 1 | export const BUCKET_NAME = "gs://be-audible.appspot.com"; 2 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module "react-voice-recorder-player"; 4 | 5 | interface ImportMetaEnv { 6 | readonly FIREBASE_PRIVATE_KEY_ID: string; 7 | readonly FIREBASE_PRIVATE_KEY: string; 8 | readonly FIREBASE_PROJECT_ID: string; 9 | readonly FIREBASE_CLIENT_EMAIL: string; 10 | readonly FIREBASE_CLIENT_ID: string; 11 | readonly FIREBASE_AUTH_URI: string; 12 | readonly FIREBASE_TOKEN_URI: string; 13 | readonly FIREBASE_AUTH_PROVIDER_CERT_URL: string; 14 | readonly FIREBASE_CLIENT_CERT_URL: string; 15 | } 16 | 17 | interface ImportMeta { 18 | readonly env: ImportMetaEnv; 19 | } 20 | -------------------------------------------------------------------------------- /src/layouts/BaseLayout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Button from "../components/Button.astro"; 3 | import Logo from "../components/Logo.astro"; 4 | 5 | type Props = { 6 | isPrivatePage?: boolean; 7 | }; 8 | 9 | const { isPrivatePage = false } = Astro.props; 10 | --- 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Be Audible 21 | 22 | 23 | 24 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /src/pages/api/auth/signout.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | 3 | export const all: APIRoute = async (ctx) => { 4 | const method = ctx.request.method; 5 | 6 | return { 7 | body: JSON.stringify({ 8 | method, 9 | message: "Unsupported HTTP method", 10 | }), 11 | status: 501, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/pages/api/recording.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | 3 | export const all: APIRoute = async (ctx) => { 4 | const method = ctx.request.method; 5 | 6 | return { 7 | body: JSON.stringify({ 8 | method, 9 | message: "Unsupported HTTP method", 10 | }), 11 | status: 501, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import H1 from "@components/H1.astro"; 3 | import Empty from "@components/Empty.astro"; 4 | import BaseLayout from "@layouts/BaseLayout.astro"; 5 | import Container from "@components/Container.astro"; 6 | --- 7 | 8 | 9 | 10 |

Hear what the world's saying

11 |

12 | Discover the voices of the world. Simply listen. 13 |

14 | 15 |
16 | 17 | 18 | 19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /src/pages/record.astro: -------------------------------------------------------------------------------- 1 | --- 2 | export const prerender = true; 3 | 4 | import H1 from "@components/H1.astro"; 5 | import LinkCTA from "@components/LinkCTA.astro"; 6 | import BaseLayout from "@layouts/BaseLayout.astro"; 7 | import Container from "@components/Container.astro"; 8 | import { Recorder } from "@components/AudioRecorder.jsx"; 9 | --- 10 | 11 | 12 | 13 |

Let your voice be heard

14 |

Simply hit record.

15 |
16 | 17 | View recordings 18 | 19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /src/pages/signin.astro: -------------------------------------------------------------------------------- 1 | --- 2 | export const prerender = true; 3 | 4 | import BaseLayout from "@layouts/BaseLayout.astro"; 5 | --- 6 | 7 | 8 |
9 |
12 |
15 |
16 |

19 | Sign in to your account 20 |

21 |
22 |
23 | 28 | 36 |
37 |
38 | 43 | 51 |
52 | 53 | 58 |

59 | Don't have an account yet? Sign up 64 |

65 |
66 |
67 |
68 |
69 |
70 |
71 | -------------------------------------------------------------------------------- /src/pages/signup.astro: -------------------------------------------------------------------------------- 1 | --- 2 | export const prerender = true; 3 | 4 | import BaseLayout from "@layouts/BaseLayout.astro"; 5 | --- 6 | 7 | 8 |
9 |
12 |
15 |
16 |

19 | Sign up: let your voice be heard 20 |

21 |
22 |
23 | 28 | 36 |
37 |
38 | 43 | 51 |
52 | 53 | 59 |

60 | Have an account? Sign in 65 |

66 |
67 |
68 |
69 |
70 |
71 |
72 | -------------------------------------------------------------------------------- /src/scripts/authClientValidationRules.ts: -------------------------------------------------------------------------------- 1 | // We could potentially bring in some TS schema validation e.g., via Zod https://github.com/colinhacks/zod 2 | 3 | export const authClientValidationRules = { 4 | email: { 5 | validate: (val: string = "") => 6 | val.includes("@") ? "" : "Please check your email address format", 7 | }, 8 | password: { 9 | validate: (val: string = "") => 10 | val.length < 6 ? "Your password is too short" : "", 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/scripts/firebase/init.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Import the Firebase client modules 3 | */ 4 | import { initializeApp } from "firebase/app"; 5 | import { getAuth, connectAuthEmulator } from "firebase/auth"; 6 | 7 | /** 8 | * The following represent the firebase client configuration for the application. 9 | * These are safe to be exposed on the client. 10 | * ⚠️ TODO: change these to match your project configuration. 11 | */ 12 | const firebaseConfig = { 13 | projectId: "XXXXXXXXXXXXXXXXXXXX", 14 | messagingSenderId: "XXXXXXXXXXXXXXXXXXXX", 15 | authDomain: "XXXXXXXXXXXXXXXXXXXX", 16 | storageBucket: "XXXXXXXXXXXXXXXXXXXX", 17 | apiKey: "XXXXXXXXXXXXXXXXXXXX", 18 | appId: "XXXXXXXXXXXXXXXXXXXX", 19 | }; 20 | 21 | // Initialize Firebase apps 22 | export const app = initializeApp(firebaseConfig); 23 | export const auth = getAuth(app); 24 | 25 | /** 26 | * For local testing add authentication emulator 27 | */ 28 | if (import.meta.env.DEV) { 29 | connectAuthEmulator(auth, "http://localhost:9098"); 30 | } 31 | -------------------------------------------------------------------------------- /src/scripts/firebase/initServer.ts: -------------------------------------------------------------------------------- 1 | import type { ServiceAccount } from "firebase-admin"; 2 | import { initializeApp, cert } from "firebase-admin/app"; 3 | 4 | export const serviceAccount = { 5 | type: "service_account", 6 | universe_domain: "googleapis.com", 7 | project_id: import.meta.env.FIREBASE_PROJECT_ID, 8 | private_key_id: import.meta.env.FIREBASE_PRIVATE_KEY_ID, 9 | private_key: import.meta.env.FIREBASE_PRIVATE_KEY, 10 | client_email: import.meta.env.FIREBASE_CLIENT_EMAIL, 11 | client_id: import.meta.env.FIREBASE_CLIENT_ID, 12 | auth_uri: import.meta.env.FIREBASE_AUTH_URI, 13 | token_uri: import.meta.env.FIREBASE_TOKEN_URI, 14 | auth_provider_x509_cert_url: import.meta.env.FIREBASE_AUTH_PROVIDER_CERT_URL, 15 | client_x509_cert_url: import.meta.env.FIREBASE_CLIENT_CERT_URL, 16 | }; 17 | 18 | /** 19 | * Firebase admin specifically checks for these 20 | * to use the emulators in development mode 21 | */ 22 | if (import.meta.env.DEV) { 23 | process.env.FIREBASE_STORAGE_EMULATOR_HOST = "localhost:3005"; 24 | process.env.FIREBASE_AUTH_EMULATOR_HOST = "localhost:9098"; 25 | } 26 | 27 | let app: ReturnType; 28 | 29 | const getServerApp = () => { 30 | if (app || admin.apps.length) { 31 | return app; 32 | } 33 | 34 | app = initializeApp({ 35 | credential: cert(serviceAccount as ServiceAccount), 36 | }); 37 | 38 | return app; 39 | }; 40 | 41 | export const serverApp = getServerApp(); 42 | -------------------------------------------------------------------------------- /src/scripts/simpleISOStringParser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @param t ISO 8601 string 3 | * e.g., from '2023-05-28T09:11:18.376Z' to "11:11:18 AM" 4 | */ 5 | 6 | export const simpleISOStringParser = (t: string) => { 7 | try { 8 | const dateTimeFormat = new Intl.DateTimeFormat([], { 9 | hour: "2-digit", 10 | minute: "2-digit", 11 | second: "2-digit", 12 | }); 13 | return dateTimeFormat.format(new Date(t)); 14 | } catch (error) { 15 | return t; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/stores/audioRecording.ts: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /storage.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service firebase.storage { 3 | match /b/{bucket}/o { 4 | match /{allPaths=**} { 5 | allow read, write: if false; 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: "class", 4 | content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"], 5 | theme: { 6 | extend: { 7 | colors: { 8 | primary: { 9 | 50: "#eff6ff", 10 | 100: "#dbeafe", 11 | 200: "#bfdbfe", 12 | 300: "#93c5fd", 13 | 400: "#60a5fa", 14 | 500: "#3b82f6", 15 | 600: "#2563eb", 16 | 700: "#1d4ed8", 17 | 800: "#1e40af", 18 | 900: "#1e3a8a", 19 | }, 20 | }, 21 | }, 22 | }, 23 | plugins: [], 24 | }; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "react", 6 | "baseUrl": ".", 7 | "paths": { 8 | "@components/*": ["src/components/*"], 9 | "@layouts/*": ["src/layouts/*"], 10 | "@scripts/*": ["src/scripts/*"], 11 | "@stores/*": ["src/stores/*"], 12 | "@constants/*": ["src/constants/*"] 13 | } 14 | } 15 | } 16 | --------------------------------------------------------------------------------