├── app ├── styles │ └── tailwind.css ├── components │ ├── ui │ │ ├── containers.tsx │ │ ├── headers.tsx │ │ ├── error-message.tsx │ │ ├── menu.tsx │ │ ├── icons.tsx │ │ ├── links.tsx │ │ ├── button.tsx │ │ ├── forms.tsx │ │ └── images.tsx │ ├── marketing │ │ ├── benefits-section.tsx │ │ ├── events-section.tsx │ │ └── hero-section.tsx │ └── layout │ │ ├── footer.tsx │ │ └── top-nav.tsx ├── modules │ ├── response │ │ └── response.server.ts │ ├── database │ │ ├── crypto.server.ts │ │ ├── db.server.ts │ │ └── groups.server.ts │ ├── session │ │ ├── google-auth.server.ts │ │ ├── buttons.tsx │ │ ├── session.server.ts │ │ └── webauthn.server.ts │ ├── background-tasks │ │ └── invoke.ts │ ├── events │ │ └── event.ts │ ├── csrf │ │ └── csrf.server.ts │ └── gpt │ │ └── generate.ts ├── routes │ ├── _auth.tsx │ ├── _auth.logout.tsx │ ├── _auth.generate-authentication-options.ts │ ├── events.tsx │ ├── $.tsx │ ├── _index.tsx │ ├── api.generate-image-caption.ts │ ├── groups_.$groupId_.events_.new.tsx │ ├── settings.tsx │ ├── search.tsx │ ├── events_.$eventId.tsx │ ├── about-us.tsx │ ├── groups.tsx │ ├── groups_.new.tsx │ ├── _auth.signup.tsx │ ├── groups_.$groupId.tsx │ └── _auth.login.tsx ├── hooks │ ├── useGroups.ts │ ├── useEnv.ts │ ├── useEvents.ts │ └── useCurrentUser.ts ├── entry.client.tsx ├── entry.server.tsx ├── root.tsx └── imgs │ ├── favicon.svg │ └── VercelBanner.svg ├── env.d.ts ├── .vscode └── extensions.json ├── postcss.config.mjs ├── public └── imgs │ ├── Hero-Image.png │ ├── upc-events1.png │ ├── upc-events2.png │ ├── upc-events3.png │ ├── 404-not-found.png │ ├── Feature-Image-1.png │ ├── Feature-Image-2.png │ ├── Feature-Image-3.png │ ├── SocialPlan-it-logo-Favicon.png │ ├── SocialPlan-it-logo-Square.png │ └── SocialPlan-it-logo-Horizontal.png ├── .prettierrc.cjs ├── .eslintignore ├── .prettierignore ├── storybook.vite.config.ts ├── .storybook ├── preview.ts └── main.ts ├── .gitignore ├── .github ├── workflows │ ├── check.yml │ ├── storybook.yml │ └── playwright.yml └── pull_request_template ├── vite.config.ts ├── stories ├── Error-Message.stories.ts ├── Image.stories.ts ├── Button.stories.ts └── Container.stories.tsx ├── tailwind.config.ts ├── tsconfig.json ├── middleware.ts ├── .env.example ├── tests └── e2e │ └── homepage-search.spec.ts ├── docs └── formatting-and-linting.md ├── .eslintrc.cjs ├── playwright.config.ts ├── package.json ├── prisma ├── schema.prisma └── seed.ts └── README.md /app/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; -------------------------------------------------------------------------------- /public/imgs/Hero-Image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/social-plan-it/plan-it-social-web/HEAD/public/imgs/Hero-Image.png -------------------------------------------------------------------------------- /public/imgs/upc-events1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/social-plan-it/plan-it-social-web/HEAD/public/imgs/upc-events1.png -------------------------------------------------------------------------------- /public/imgs/upc-events2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/social-plan-it/plan-it-social-web/HEAD/public/imgs/upc-events2.png -------------------------------------------------------------------------------- /public/imgs/upc-events3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/social-plan-it/plan-it-social-web/HEAD/public/imgs/upc-events3.png -------------------------------------------------------------------------------- /public/imgs/404-not-found.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/social-plan-it/plan-it-social-web/HEAD/public/imgs/404-not-found.png -------------------------------------------------------------------------------- /public/imgs/Feature-Image-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/social-plan-it/plan-it-social-web/HEAD/public/imgs/Feature-Image-1.png -------------------------------------------------------------------------------- /public/imgs/Feature-Image-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/social-plan-it/plan-it-social-web/HEAD/public/imgs/Feature-Image-2.png -------------------------------------------------------------------------------- /public/imgs/Feature-Image-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/social-plan-it/plan-it-social-web/HEAD/public/imgs/Feature-Image-3.png -------------------------------------------------------------------------------- /public/imgs/SocialPlan-it-logo-Favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/social-plan-it/plan-it-social-web/HEAD/public/imgs/SocialPlan-it-logo-Favicon.png -------------------------------------------------------------------------------- /public/imgs/SocialPlan-it-logo-Square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/social-plan-it/plan-it-social-web/HEAD/public/imgs/SocialPlan-it-logo-Square.png -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /public/imgs/SocialPlan-it-logo-Horizontal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/social-plan-it/plan-it-social-web/HEAD/public/imgs/SocialPlan-it-logo-Horizontal.png -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .cache 4 | .env 5 | .vercel 6 | .output 7 | 8 | /build/ 9 | /public/build 10 | /api/index.js 11 | /api/index.js.map 12 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .cache 4 | .env 5 | .vercel 6 | .output 7 | 8 | /build/ 9 | /public/build 10 | /api/index.js 11 | /api/index.js.map 12 | -------------------------------------------------------------------------------- /storybook.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import tsconfigPaths from 'vite-tsconfig-paths'; 3 | 4 | export default defineConfig({ 5 | plugins: [tsconfigPaths()], 6 | }); 7 | -------------------------------------------------------------------------------- /app/components/ui/containers.tsx: -------------------------------------------------------------------------------- 1 | export function Card({ children }: { children: React.ReactNode }) { 2 | return
{children}
; 3 | } 4 | -------------------------------------------------------------------------------- /app/modules/response/response.server.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@remix-run/node'; 2 | 3 | /** 4 | * Helper function to return 400 Bad Request response to the client. 5 | */ 6 | export function badRequest(data: T) { 7 | return json(data, { status: 400 }); 8 | } 9 | -------------------------------------------------------------------------------- /app/routes/_auth.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from '@remix-run/react'; 2 | 3 | export default function Component() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /app/components/ui/headers.tsx: -------------------------------------------------------------------------------- 1 | export function H1({ children }: { children: React.ReactNode }) { 2 | return

{children}

; 3 | } 4 | 5 | export function H2({ children }: { children: React.ReactNode }) { 6 | return

{children}

; 7 | } 8 | -------------------------------------------------------------------------------- /app/modules/database/crypto.server.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs'; 2 | 3 | export function getHash(password: string): Promise { 4 | return bcrypt.hash(password, 10); 5 | } 6 | 7 | export function matchesHash(password: string, hash: string): Promise { 8 | return bcrypt.compare(password, hash); 9 | } 10 | -------------------------------------------------------------------------------- /app/hooks/useGroups.ts: -------------------------------------------------------------------------------- 1 | import type { SerializeFrom } from '@remix-run/node'; 2 | import { useRouteLoaderData } from '@remix-run/react'; 3 | import type { loader } from '~/root'; 4 | 5 | export function useGroups() { 6 | const data = useRouteLoaderData('root') as SerializeFrom | undefined; 7 | 8 | return data?.groups; 9 | } 10 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react' 2 | import '../app/styles/tailwind.css'; 3 | 4 | const preview: Preview = { 5 | parameters: { 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/i, 10 | }, 11 | }, 12 | }, 13 | }; 14 | 15 | export default preview; -------------------------------------------------------------------------------- /app/routes/_auth.logout.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunctionArgs } from '@remix-run/node'; 2 | import { redirect } from '@remix-run/node'; 3 | 4 | import { logout } from '~/modules/session/session.server'; 5 | 6 | export async function action({ request }: ActionFunctionArgs) { 7 | return logout(request); 8 | } 9 | 10 | export async function loader() { 11 | return redirect('/'); 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .cache 4 | .env 5 | .vercel 6 | .output 7 | .vscode/settings.json 8 | 9 | /build/ 10 | /public/build 11 | /api/index.js 12 | /api/index.js.map 13 | /api/metafile.css.json 14 | /api/metafile.js.json 15 | /api/metafile.server.json 16 | 17 | .DS_Store 18 | /test-results/ 19 | /playwright-report/ 20 | /blob-report/ 21 | /playwright/.cache/ 22 | 23 | *storybook.log 24 | /storybook-static/ -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Linting and Typechecking 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Install dependencies 13 | run: npm ci 14 | - name: Run ESLint 15 | run: npm run lint 16 | - name: Run Typecheck 17 | run: npm run typecheck 18 | -------------------------------------------------------------------------------- /app/hooks/useEnv.ts: -------------------------------------------------------------------------------- 1 | import type { SerializeFrom } from '@remix-run/node'; 2 | import { useRouteLoaderData } from '@remix-run/react'; 3 | 4 | import type { loader } from '~/root'; 5 | 6 | export function useEnv() { 7 | const data = useRouteLoaderData('root') as SerializeFrom | undefined; 8 | 9 | if (!data?.ENV) { 10 | throw new Error('Client environmental variables must be set.'); 11 | } 12 | 13 | return data.ENV; 14 | } 15 | -------------------------------------------------------------------------------- /app/modules/session/google-auth.server.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2Client } from 'google-auth-library'; 2 | 3 | const client = new OAuth2Client(); 4 | 5 | /** Verify JWT token from Google authentication and return decoded payload. */ 6 | export async function verifyGoogleToken(idToken: string) { 7 | const ticket = await client.verifyIdToken({ 8 | idToken, 9 | audience: process.env.PUBLIC_GOOGLE_CLIENT_ID, 10 | }); 11 | 12 | return ticket.getPayload(); 13 | } 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from '@remix-run/dev'; 2 | import { defineConfig } from 'vite'; 3 | import tsconfigPaths from 'vite-tsconfig-paths'; 4 | import { vercelPreset } from '@vercel/remix/vite'; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | remix({ 9 | ignoredRouteFiles: ['**/*.css'], 10 | presets: [vercelPreset()], 11 | }), 12 | tsconfigPaths(), 13 | ], 14 | server: { 15 | port: 3000, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /stories/Error-Message.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { ErrorMessage } from '../app/components/ui/error-message'; 4 | 5 | const meta: Meta = { 6 | title: 'ui/Error-Message', 7 | component: ErrorMessage, 8 | tags: ['autodocs'], 9 | }; 10 | 11 | export default meta; 12 | type Story = StoryObj; 13 | 14 | export const ShortText: Story = { 15 | args: { 16 | children: 'children', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /app/hooks/useEvents.ts: -------------------------------------------------------------------------------- 1 | import type { SerializeFrom } from '@remix-run/node'; 2 | import { useRouteLoaderData } from '@remix-run/react'; 3 | import type { loader } from '~/root'; 4 | 5 | export function useEvents() { 6 | const data = useRouteLoaderData('root') as SerializeFrom | undefined; 7 | const events = data?.events; 8 | const deserializedEvents = events?.map((event) => ({ 9 | ...event, 10 | date: new Date(event.date), 11 | })); 12 | 13 | return deserializedEvents; 14 | } 15 | -------------------------------------------------------------------------------- /app/modules/background-tasks/invoke.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@upstash/qstash'; 2 | 3 | export async function invokeBackgroundTask(url: string, body: unknown) { 4 | if (process.env.ENABLE_UPSTASH !== 'true') { 5 | return; 6 | } 7 | if (!process.env.QSTASH_TOKEN) { 8 | throw new Error('QSTASH_TOKEN is not defined. Please define it in the .env file.'); 9 | } 10 | const qstashClient = new Client({ 11 | token: process.env.QSTASH_TOKEN!, 12 | }); 13 | 14 | return qstashClient.publishJSON({ url, body }); 15 | } 16 | -------------------------------------------------------------------------------- /app/components/ui/error-message.tsx: -------------------------------------------------------------------------------- 1 | type ErrorProps = { 2 | children: React.ReactNode; 3 | }; 4 | 5 | export function ErrorMessage({ children }: ErrorProps) { 6 | return ( 7 |
8 |
12 |
{children}
13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/routes/_auth.generate-authentication-options.ts: -------------------------------------------------------------------------------- 1 | import { db } from '~/modules/database/db.server'; 2 | import { getPasskeyAuthenticationOptions } from '~/modules/session/webauthn.server'; 3 | 4 | import type { ActionFunctionArgs } from '@remix-run/node'; 5 | 6 | export async function action({ request }: ActionFunctionArgs) { 7 | const { email } = await request.json(); 8 | const user = await db.user.findUnique({ where: { email }, include: { authenticators: true } }); 9 | if (!user) return null; 10 | 11 | const options = await getPasskeyAuthenticationOptions(user); 12 | return options; 13 | } 14 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | import defaultTheme from 'tailwindcss/defaultTheme'; 3 | 4 | export default { 5 | content: ['./app/**/*.{js,jsx,ts,tsx}'], 6 | theme: { 7 | extend: { 8 | colors: { 9 | primary: '#1F2937', 10 | secondary: '#E4EFF0', 11 | warm: '#E11D48', 12 | grayBackground: 'rgba(217, 217, 217, 0.25)', 13 | }, 14 | fontFamily: { 15 | sans: ['Montserrat', ...defaultTheme.fontFamily.sans], 16 | }, 17 | }, 18 | }, 19 | plugins: [require('@tailwindcss/forms')], 20 | } satisfies Config; 21 | -------------------------------------------------------------------------------- /app/hooks/useCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import type { SerializeFrom } from '@remix-run/node'; 2 | import { useRouteLoaderData } from '@remix-run/react'; 3 | 4 | import type { loader } from '~/root'; 5 | 6 | export function useCurrentUser() { 7 | // Type assertion is used here since useRouteLoaderData does not currently accept a generic. 8 | // References: 9 | // - https://github.com/remix-run/remix/discussions/5061 10 | // - https://github.com/remix-run/remix/discussions/6858 11 | const data = useRouteLoaderData('root') as SerializeFrom | undefined; 12 | 13 | return data?.currentUser; 14 | } 15 | -------------------------------------------------------------------------------- /app/modules/events/event.ts: -------------------------------------------------------------------------------- 1 | import type { Event, Group, User } from '@prisma/client'; 2 | 3 | export interface FullEvent extends Event { 4 | group?: Group; 5 | users?: User[]; 6 | } 7 | 8 | type RawEvent = Omit & { date: string }; 9 | 10 | export function eventDataPatcher(event: RawEvent): FullEvent { 11 | return { 12 | ...event, 13 | date: new Date(event.date), 14 | }; 15 | } 16 | 17 | export function eventsDataPatcher(events: RawEvent[]): FullEvent[] { 18 | if (!events) { 19 | return []; 20 | } 21 | 22 | return events.map((event) => { 23 | return eventDataPatcher(event); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/docs/en/main/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from '@remix-run/react'; 8 | import { startTransition, StrictMode } from 'react'; 9 | import { hydrateRoot } from 'react-dom/client'; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | , 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /app/modules/database/db.server.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | let db: PrismaClient; 4 | 5 | declare global { 6 | // eslint-disable-next-line no-var 7 | var __db: PrismaClient | undefined; 8 | } 9 | 10 | // this is needed because in development we don't want to restart 11 | // the server with every change, but we want to make sure we don't 12 | // create a new connection to the DB with every change either. 13 | if (process.env.NODE_ENV === 'production') { 14 | db = new PrismaClient(); 15 | } else { 16 | if (!global.__db) { 17 | global.__db = new PrismaClient(); 18 | } 19 | db = global.__db; 20 | } 21 | 22 | export { db }; 23 | -------------------------------------------------------------------------------- /stories/Image.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { Image } from '../app/components/ui/images'; 4 | 5 | const meta: Meta = { 6 | title: 'ui/Images', 7 | component: Image, 8 | tags: ['autodocs'], 9 | argTypes: { 10 | priority: { 11 | control: { type: 'boolean' }, 12 | }, 13 | }, 14 | }; 15 | 16 | export default meta; 17 | type Story = StoryObj; 18 | 19 | export const ShortText: Story = { 20 | args: { 21 | alt: 'alternative image text', 22 | src: 'https://via.placeholder.com/100', 23 | width: 100, 24 | height: 100, 25 | priority: true, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | 3 | const config: StorybookConfig = { 4 | "stories": [ 5 | "../stories/**/*.mdx", 6 | "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)" 7 | ], 8 | "addons": [ 9 | "@storybook/addon-onboarding", 10 | "@storybook/addon-links", 11 | "@storybook/addon-essentials", 12 | "@chromatic-com/storybook", 13 | "@storybook/addon-interactions" 14 | ], 15 | "framework": { 16 | "name": "@storybook/react-vite", 17 | "options": { 18 | builder: { 19 | viteConfigPath: 'storybook.vite.config.ts' 20 | } 21 | } 22 | }, 23 | "docs": { 24 | "autodocs": "tag" 25 | }, 26 | }; 27 | export default config; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["env.d.ts", "**/*.ts", "**/*.tsx", ".storybook/vite.config.ts"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "module": "ESNext", 9 | "moduleResolution": "Bundler", 10 | "resolveJsonModule": true, 11 | "target": "ES2019", 12 | "strict": true, 13 | "allowJs": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "skipLibCheck": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./app/*"] 19 | }, 20 | 21 | // Remix takes care of building everything in `remix build`. 22 | "noEmit": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/modules/csrf/csrf.server.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto'; 2 | import { badRequest } from '../response/response.server'; 3 | 4 | export function createCsrfToken() { 5 | return crypto.randomUUID(); 6 | } 7 | 8 | async function validateCsrfToken(tokenFromSession: string | null, token: FormDataEntryValue | null) { 9 | if (!tokenFromSession) { 10 | return false; 11 | } 12 | return tokenFromSession === token; 13 | } 14 | 15 | export async function requireValidCsrfToken(tokenFromSession: string | null, token: FormDataEntryValue | null) { 16 | const isValid = await validateCsrfToken(tokenFromSession, token); 17 | if (!isValid) { 18 | throw badRequest({ 19 | success: false, 20 | error: { message: 'Invalid CSRF Token' }, 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { ipAddress, next } from '@vercel/edge'; 2 | import { Ratelimit } from '@upstash/ratelimit'; 3 | import { kv } from '@vercel/kv'; 4 | 5 | console.log('middleware.ts'); 6 | 7 | const ratelimit = new Ratelimit({ 8 | redis: kv, 9 | // 5 requests from the same IP in 10 seconds 10 | limiter: Ratelimit.slidingWindow(5, '10 s'), 11 | }); 12 | 13 | // Define which routes you want to rate limit 14 | export const config = { 15 | matcher: '/', 16 | }; 17 | 18 | export default async function middleware(request: Request) { 19 | console.log('running middleware'); 20 | // You could alternatively limit based on user ID or similar 21 | const ip = ipAddress(request) || '127.0.0.1'; 22 | const { success } = await ratelimit.limit(ip); 23 | 24 | return success ? next() : Response.redirect(new URL('/blocked.html', request.url)); 25 | } 26 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # General 2 | DOMAIN="http://localhost:3000" # or your own domain 3 | 4 | # Flags 5 | ENABLE_UPSTASH=false 6 | MOCK_OPENAI=false 7 | 8 | # Auth 9 | SESSION_SECRET="ANY_RANDOM_STRING_HERE" 10 | 11 | # Sign in with Google 12 | PUBLIC_GOOGLE_CLIENT_ID='ASK_US_IN_THE_DISCORD_CHANNEL' 13 | 14 | # PASSKEY 15 | WEBAUTHN_RELYING_PARTY_ID="ANY_RANDOM_STRING_HERE" 16 | 17 | # DB 18 | # replace SUPABASE_DB_URL with your own postgres url 19 | # they may be the same if not using postgres and not supabase as the service provider 20 | DATABASE_URL="postgres://SUPABASE_DB_URL" 21 | DIRECT_URL="postgres://SUPABASE_DR_URL" 22 | 23 | # Supabase 24 | # Server-side secret (service_role secret) to bypass Row Level Security. Never share it publicly. 25 | SUPABASE_SECRET="SUPABASE_SECRET" 26 | 27 | # OpenAI 28 | OPENAI_API_KEY="OPENAI_API_KEY" 29 | 30 | # Upstash 31 | QSTASH_TOKEN="QSTASH_TOKEN" -------------------------------------------------------------------------------- /app/modules/database/groups.server.ts: -------------------------------------------------------------------------------- 1 | import { db } from './db.server'; 2 | import type { Prisma, Role } from '@prisma/client'; 3 | 4 | export type FindGroupsArgs = { 5 | count?: number; 6 | skip?: number; 7 | query?: string; 8 | userId?: string; 9 | role?: Role; 10 | }; 11 | 12 | export async function findGroups(this: any, { count = 24, skip, query, userId, role }: FindGroupsArgs) { 13 | const whereArg: Prisma.GroupWhereInput = {}; 14 | 15 | if (query) { 16 | whereArg.OR = [{ name: { contains: query } }, { description: { contains: query } }]; 17 | } 18 | 19 | if (userId && role) { 20 | whereArg.user_groups = { some: { userId, role } }; 21 | } 22 | 23 | const result = await db.$transaction([ 24 | db.group.findMany({ 25 | where: whereArg, 26 | skip: skip, 27 | take: count, 28 | }), 29 | db.group.count({ where: whereArg }), 30 | ]); 31 | 32 | return result; 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/storybook.yml: -------------------------------------------------------------------------------- 1 | name: Publish Storybook to GitHub Pages 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: [main] 6 | permissions: 7 | contents: read 8 | pages: write 9 | id-token: write 10 | jobs: 11 | build-and-deploy: 12 | timeout-minutes: 60 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 20 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Build Storybook 22 | run: npm run build-storybook 23 | - name: Setup Pages 24 | uses: actions/configure-pages@v3 25 | - name: Upload Artifact 26 | uses: actions/upload-pages-artifact@v2 27 | with: 28 | path: './storybook-static' 29 | - name: Deploy to GitHub Pages 30 | id: deployment 31 | uses: actions/deploy-pages@v2 32 | -------------------------------------------------------------------------------- /tests/e2e/homepage-search.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('Homepage Search', () => { 4 | test('it should navigate to search page when searching', async ({ page }) => { 5 | await page.goto('/'); 6 | await page.getByTestId('hero-section-search-input').fill('playwright'); 7 | await page.getByTestId('hero-section-submit-button').click(); 8 | 9 | await expect(page).toHaveTitle('Search'); 10 | await expect(page).toHaveURL('/search?q=playwright'); 11 | }); 12 | 13 | test('it should show empty search page if query does not match anything', async ({ page }) => { 14 | await page.goto('/'); 15 | await page.getByTestId('hero-section-search-input').fill('no group matches this sad search query'); 16 | await page.getByTestId('hero-section-submit-button').click(); 17 | 18 | await expect(page.getByTestId('no-results-text')).toHaveText('No events found. Please, try another keywords.'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /app/components/ui/menu.tsx: -------------------------------------------------------------------------------- 1 | import { Menu, Transition } from '@headlessui/react'; 2 | import { type ReactElement } from 'react'; 3 | 4 | export function StyledMenu({ button, children }: { button: any; children: ReactElement }) { 5 | return ( 6 | 7 |
8 | {button} 9 |
10 | 18 | 19 | {children} 20 | 21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/routes/events.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from '@remix-run/node'; 2 | import { useEvents } from '~/hooks/useEvents'; 3 | import { EventCard } from '~/components/marketing/events-section'; 4 | 5 | export default function EventsPage() { 6 | const events = useEvents(); 7 | 8 | return ( 9 |
10 |

Upcoming Events

11 |
12 | {events?.map((event, index) => { 13 | return ; 14 | })} 15 |
16 |
17 | ); 18 | } 19 | 20 | export const meta: MetaFunction = () => { 21 | return [ 22 | { title: 'Events | Social Plan-It' }, 23 | { name: 'description', content: "You're welcome to join us in any event. Looking forward to seeing you there!" }, 24 | ]; 25 | }; 26 | -------------------------------------------------------------------------------- /docs/formatting-and-linting.md: -------------------------------------------------------------------------------- 1 | # Formatting and linting 2 | 3 | ## Motivation 4 | 5 | Prettier and ESLint are introduced in [this PR](https://github.com/social-plan-it/plan-it-social-web/pull/10). You can find the motivation for the setup in the PR description and review comments. 6 | 7 | ## Commands 8 | 9 | - `npm run lint` - reports all current formatting and linting violations. 10 | 11 | ## VS Code setup 12 | 13 | Use the following VS Code workspace settings to set up ESLint and Prettier on save: 14 | 15 | ```json 16 | { 17 | "editor.defaultFormatter": "esbenp.prettier-vscode", 18 | "editor.formatOnSave": true, 19 | "editor.codeActionsOnSave": { 20 | "source.fixAll": true 21 | }, 22 | "[javascript]": { 23 | "editor.defaultFormatter": "esbenp.prettier-vscode" 24 | }, 25 | "[json]": { 26 | "editor.defaultFormatter": "esbenp.prettier-vscode" 27 | }, 28 | "[typescript]": { 29 | "editor.defaultFormatter": "esbenp.prettier-vscode" 30 | }, 31 | "eslint.lintTask.enable": true 32 | } 33 | ``` 34 | -------------------------------------------------------------------------------- /app/routes/$.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLocation } from '@remix-run/react'; 2 | import { ErrorMessage } from '~/components/ui/error-message'; 3 | 4 | export async function loader() { 5 | throw new Response('Not found', { status: 404 }); 6 | } 7 | 8 | export function ErrorBoundary() { 9 | const location = useLocation(); 10 | 11 | return ( 12 | 13 |
14 | Page not found 19 |

Page not found

20 |

21 | The page {location.pathname} you are looking for does not exist. 22 |

23 | 24 | Go back home 25 | 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const config = require('./.prettierrc.cjs'); 3 | 4 | /** @type {import('eslint').Linter.Config} */ 5 | module.exports = { 6 | parser: '@typescript-eslint/parser', 7 | extends: ['plugin:react/recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'prettier', '@remix-run/eslint-config', '@remix-run/eslint-config/node', 'plugin:storybook/recommended'], 8 | plugins: ['@typescript-eslint', 'html', 'jsx-a11y'], 9 | parserOptions: { 10 | ecmaVersion: 2020, 11 | sourceType: 'module', 12 | ecmaFeatures: { 13 | jsx: true, // Allows for the parsing of JSX 14 | }, 15 | }, 16 | settings: { 17 | react: { 18 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use 19 | }, 20 | }, 21 | rules: { 22 | 'prettier/prettier': ['error', config], 23 | '@typescript-eslint/consistent-type-imports': [ 24 | 'error', 25 | { 26 | prefer: 'type-imports', 27 | disallowTypeAnnotations: true, 28 | }, 29 | ], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 20 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Install Playwright Browsers 19 | run: npx playwright install --with-deps 20 | - name: Run Playwright tests 21 | run: npx playwright test 22 | env: 23 | DATABASE_URL: ${{ secrets.DATABASE_URL }} 24 | PUBLIC_GOOGLE_CLIENT_ID: ${{ secrets.PUBLIC_GOOGLE_CLIENT_ID }} 25 | SESSION_SECRET: ${{ secrets.SESSION_SECRET }} 26 | WEBAUTHN_RELYING_PARTY_ID: ${{ secrets.WEBAUTHN_RELYING_PARTY_ID}} 27 | DOMAIN: ${{ secrets.DOMAIN}} 28 | - uses: actions/upload-artifact@v3 29 | if: always() 30 | with: 31 | name: playwright-report 32 | path: playwright-report/ 33 | retention-days: 30 34 | -------------------------------------------------------------------------------- /app/modules/session/buttons.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { useEnv } from '~/hooks/useEnv'; 4 | 5 | export function SignInWithGoogleButton() { 6 | const env = useEnv(); 7 | 8 | useEffect(() => { 9 | const head = document.querySelector('head'); 10 | const script = document.createElement('script'); 11 | 12 | script.src = 'https://accounts.google.com/gsi/client'; 13 | head?.appendChild(script); 14 | 15 | return () => { 16 | head?.removeChild(script); 17 | }; 18 | }, []); 19 | 20 | return ( 21 | <> 22 |
30 | 31 |
41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /stories/Button.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { Button } from '../app/components/ui/button'; 3 | 4 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 5 | const meta: Meta = { 6 | title: 'ui/Button', 7 | component: Button, 8 | }; 9 | 10 | export default meta; 11 | type Story = StoryObj; 12 | 13 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 14 | export const PrimaryRounded: Story = { 15 | args: { 16 | variant: 'primary', 17 | buttonStyle: 'rounded', 18 | children: 'Button', 19 | }, 20 | }; 21 | 22 | export const PrimaryFullyRounded: Story = { 23 | args: { 24 | variant: 'primary', 25 | buttonStyle: 'fullyRounded', 26 | children: 'Button', 27 | }, 28 | }; 29 | 30 | export const SecondaryRounded: Story = { 31 | args: { 32 | variant: 'secondary', 33 | buttonStyle: 'rounded', 34 | children: 'Button', 35 | }, 36 | }; 37 | 38 | export const SecondaryFullyRounded: Story = { 39 | args: { 40 | variant: 'secondary', 41 | buttonStyle: 'fullyRounded', 42 | children: 'Button', 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /.github/pull_request_template: -------------------------------------------------------------------------------- 1 | 4 | 5 | Issue: # 6 | 7 | ## Summary (1-2 sentences) 8 | 9 | ## Details (reason and description of the changes) 10 | 11 | ### Challenges (what problems did I face?) (optional) 12 | 13 | ### Alternative implementations (what alternatives were considered and why were they disregarded?) (optional) 14 | 15 | ### Solution (how did I fix the problems?) (optional) 16 | 17 | ### Sources (links that helped to resolve these challenges.) (optional) 18 | 19 | ## Screenshots/Recordings (if applicable) 20 | 21 | 24 | 25 | ### Implementation details (how was this change implemented?) (optional) 26 | 27 | 37 | -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from '@remix-run/react'; 2 | import { type MetaFunction } from '@remix-run/react'; 3 | import { db } from '~/modules/database/db.server'; 4 | import HeroSection from '~/components/marketing/hero-section'; 5 | import { EventsSection } from '../components/marketing/events-section'; 6 | import { BenefitsSection } from '~/components/marketing/benefits-section'; 7 | import { eventsDataPatcher } from '~/modules/events/event'; 8 | 9 | export const meta: MetaFunction = () => { 10 | return [ 11 | { title: 'Home | Social Plan-It' }, 12 | { 13 | name: 'description', 14 | content: 'Social Plan-It is where you can join groups and events to collaborate to learn and meet new friends.', 15 | }, 16 | ]; 17 | }; 18 | 19 | export async function loader() { 20 | const events = await db.event.findMany({ take: 3, include: { group: true } }); 21 | return { events }; 22 | } 23 | 24 | export default function Index() { 25 | const { events } = useLoaderData(); 26 | const deserializedEvents = eventsDataPatcher(events); 27 | 28 | return ( 29 | <> 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/modules/gpt/generate.ts: -------------------------------------------------------------------------------- 1 | import { OpenAI } from 'openai'; 2 | 3 | export async function generateTextContent(prompt: string, imageUrl?: string) { 4 | if (process.env.MOCK_OPENAI === 'true') { 5 | console.log( 6 | 'OpenAI is disabled. Returning a mock response. This can be changed in .env file by setting MOCK_OPENAI to false', 7 | ); 8 | return 'OpenAI is disabled. This is a mock response.'; 9 | } 10 | if (!process.env.OPENAI_API_KEY) { 11 | throw new Error('OpenAI API key is not set. Set OPENAI_API_KEY in .env file.'); 12 | } 13 | const openai = new OpenAI({ 14 | apiKey: process.env.OPENAI_API_KEY, 15 | }); 16 | const content: OpenAI.Chat.Completions.ChatCompletionContentPart[] = [{ type: 'text', text: prompt }]; 17 | if (imageUrl) { 18 | content.push({ 19 | type: 'image_url', 20 | image_url: { 21 | url: imageUrl, 22 | detail: 'auto', 23 | }, 24 | }); 25 | } 26 | const response = await openai.chat.completions.create({ 27 | model: 'gpt-4o', 28 | messages: [ 29 | { 30 | role: 'user', 31 | content, 32 | }, 33 | ], 34 | }); 35 | if (!response.choices[0].message.content) { 36 | throw new Error('No response from OpenAI'); 37 | } 38 | return response.choices[0].message.content; 39 | } 40 | -------------------------------------------------------------------------------- /app/components/ui/icons.tsx: -------------------------------------------------------------------------------- 1 | export function UserCircleIcon() { 2 | return ( 3 | 11 | 16 | 17 | ); 18 | } 19 | 20 | export function KeyIcon() { 21 | return ( 22 | 23 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/routes/api.generate-image-caption.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunctionArgs } from '@remix-run/node'; 2 | import { db } from '~/modules/database/db.server'; 3 | import { generateTextContent } from '~/modules/gpt/generate'; 4 | 5 | /** 6 | * Open endpoint to generate an image caption for a group image. 7 | * POST /api/generate-image-caption { groupId: string } 8 | */ 9 | export async function action({ request }: ActionFunctionArgs) { 10 | const body = await request.json(); 11 | 12 | const groupId = body.groupId; 13 | const group = await db.group.findUnique({ where: { id: groupId } }); 14 | if (!group) { 15 | return new Response('Group not found', { status: 404 }); 16 | } 17 | 18 | const groupImageUrl = group.imgUrl; 19 | if (!groupImageUrl) { 20 | return new Response('Group image not found', { status: 404 }); 21 | } 22 | 23 | try { 24 | const altText = await generateTextContent( 25 | 'Create an alt text (image caption) for the provided image please.', 26 | groupImageUrl, 27 | ); 28 | await db.group.update({ where: { id: groupId }, data: { imgAlt: altText } }); 29 | return new Response('Image caption generated', { status: 200 }); 30 | } catch (error) { 31 | console.error(error); 32 | return new Response('Error generating image caption', { status: 500 }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/components/ui/links.tsx: -------------------------------------------------------------------------------- 1 | import type { HTMLAttributes, ReactNode } from 'react'; 2 | import type { NavLinkProps } from '@remix-run/react'; 3 | import { NavLink } from '@remix-run/react'; 4 | 5 | type LinkProps = NavLinkProps & { 6 | className?: HTMLAttributes['className']; 7 | to: string; 8 | }; 9 | 10 | /** 11 | * Reusable link component. 12 | * Classes are applied to a wrapping div to allow for flexbox 13 | * and other layout styles. 14 | */ 15 | export function Link(props: LinkProps) { 16 | return ( 17 |
18 | 19 | {props.children} 20 | 21 |
22 | ); 23 | } 24 | 25 | type LinkButtonProps = { 26 | children: ReactNode; 27 | to: string; 28 | preventScrollReset?: boolean; 29 | prefetch?: LinkProps['prefetch']; 30 | }; 31 | 32 | export function LinkButton({ 33 | to, 34 | children, 35 | preventScrollReset = true, 36 | prefetch = 'intent', 37 | ...props 38 | }: LinkButtonProps) { 39 | return ( 40 | 47 | {children} 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/components/marketing/benefits-section.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@remix-run/react'; 2 | import { staticImage, Image } from '../ui/images'; 3 | 4 | const benefits = [ 5 | { 6 | title: 'Join a group', 7 | message: 'Do what you love, meet others who love it, find your community. The rest is history!', 8 | imgUrl: staticImage.womenCollaborating.url, 9 | imgAlt: staticImage.womenCollaborating.altText, 10 | imgTitle: staticImage.womenCollaborating.title, 11 | link: '/groups', 12 | }, 13 | { 14 | title: 'Find an event', 15 | message: 16 | 'Events are happening on just about any topic you can think of, from online gaming and photography to yoga and hiking.', 17 | imgUrl: staticImage.friendsOnABench.url, 18 | imgAlt: staticImage.friendsOnABench.altText, 19 | imgTitle: staticImage.friendsOnABench.title, 20 | link: '/events', 21 | }, 22 | { 23 | title: 'Start a group', 24 | message: 'You don’t have to be an expert to gather people together and explore shared interests.', 25 | imgUrl: staticImage.womenOnStaircase.url, 26 | imgAlt: staticImage.womenOnStaircase.altText, 27 | imgTitle: staticImage.womenOnStaircase.title, 28 | link: '/groups/new', 29 | }, 30 | ]; 31 | 32 | export function BenefitsSection() { 33 | return ( 34 |
35 |
36 | {benefits.map((benefit, index) => ( 37 | 38 |
39 |
40 | {benefit.imgAlt} 48 |
49 |

{benefit.title}

50 |

{benefit.message}

51 |
52 | 53 | ))} 54 |
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './tests/e2e', 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: 'html', 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | baseURL: 'http://localhost:3000', 28 | 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: 'on-first-retry', 31 | }, 32 | 33 | /* Configure projects for major browsers */ 34 | projects: [ 35 | { 36 | name: 'chromium', 37 | use: { ...devices['Desktop Chrome'] }, 38 | }, 39 | 40 | { 41 | name: 'firefox', 42 | use: { ...devices['Desktop Firefox'] }, 43 | }, 44 | 45 | { 46 | name: 'webkit', 47 | use: { ...devices['Desktop Safari'] }, 48 | }, 49 | 50 | /* Test against mobile viewports. */ 51 | // { 52 | // name: 'Mobile Chrome', 53 | // use: { ...devices['Pixel 5'] }, 54 | // }, 55 | // { 56 | // name: 'Mobile Safari', 57 | // use: { ...devices['iPhone 12'] }, 58 | // }, 59 | 60 | /* Test against branded browsers. */ 61 | // { 62 | // name: 'Microsoft Edge', 63 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 64 | // }, 65 | // { 66 | // name: 'Google Chrome', 67 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 68 | // }, 69 | ], 70 | 71 | /* Run your local dev server before starting the tests */ 72 | webServer: { 73 | command: 'npm run dev', 74 | url: 'http://localhost:3000', 75 | reuseExistingServer: !process.env.CI, 76 | }, 77 | }); 78 | -------------------------------------------------------------------------------- /app/modules/session/session.server.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage, redirect } from '@remix-run/node'; 2 | 3 | import { db } from '~/modules/database/db.server'; 4 | import { createCsrfToken } from '../csrf/csrf.server'; 5 | 6 | const sessionSecret = process.env.SESSION_SECRET; 7 | if (!sessionSecret) { 8 | throw new Error('SESSION_SECRET must be set'); 9 | } 10 | 11 | const { commitSession, destroySession, getSession } = createCookieSessionStorage({ 12 | cookie: { 13 | name: 'session', 14 | // normally you want this to be `secure: true` 15 | // but that doesn't work on localhost for Safari 16 | // https://web.dev/when-to-use-local-https/ 17 | secure: true, 18 | secrets: [sessionSecret], 19 | sameSite: 'lax', 20 | path: '/', 21 | maxAge: 60 * 60 * 24 * 30, 22 | httpOnly: true, 23 | }, 24 | }); 25 | 26 | export async function createUserSession(userId: string): Promise { 27 | const headers = new Headers(); 28 | 29 | const cookie = await getSession(); 30 | cookie.set('userId', userId); 31 | cookie.set('csrfToken', createCsrfToken()); 32 | 33 | const cookieValue = await commitSession(cookie); 34 | headers.set('Set-Cookie', cookieValue); 35 | 36 | return headers; 37 | } 38 | 39 | export type UserSession = { 40 | userId: string; 41 | csrfToken: string; 42 | }; 43 | 44 | export async function getUserSession(request: Request): Promise { 45 | const cookie = await getSession(request.headers.get('Cookie')); 46 | if (cookie && cookie.get('userId')) { 47 | return { userId: cookie.get('userId'), csrfToken: cookie.get('csrfToken') }; 48 | } 49 | return null; 50 | } 51 | 52 | export async function getCurrentUser(request: Request) { 53 | const session = await getUserSession(request); 54 | 55 | if (session) { 56 | const currentUser = await db.user.findUnique({ where: { id: session.userId } }); 57 | 58 | if (currentUser) { 59 | return currentUser; 60 | } 61 | } 62 | 63 | return null; 64 | } 65 | 66 | export async function logout(request: Request) { 67 | const cookie = await getSession(request.headers.get('Cookie')); 68 | 69 | return redirect('/login', { 70 | headers: { 71 | 'Set-Cookie': await destroySession(cookie), 72 | }, 73 | }); 74 | } 75 | 76 | export async function requireUserSession(request: Request) { 77 | const session = await getUserSession(request); 78 | if (!session) { 79 | throw redirect('/login'); 80 | } 81 | return session; 82 | } 83 | -------------------------------------------------------------------------------- /app/routes/groups_.$groupId_.events_.new.tsx: -------------------------------------------------------------------------------- 1 | import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'; 2 | import { redirect } from '@remix-run/node'; 3 | import { Form } from '@remix-run/react'; 4 | 5 | import { db } from '~/modules/database/db.server'; 6 | import { Card } from '~/components/ui/containers'; 7 | import { Input, TextArea } from '~/components/ui/forms'; 8 | import { Button } from '~/components/ui/button'; 9 | import { H1 } from '~/components/ui/headers'; 10 | import { requireUserSession } from '~/modules/session/session.server'; 11 | 12 | export async function loader({ request }: LoaderFunctionArgs) { 13 | return requireUserSession(request); 14 | } 15 | 16 | export async function action({ request, params }: ActionFunctionArgs) { 17 | const form = await request.formData(); 18 | const name = form.get('name') as string | null; 19 | const date = form.get('date') as Date | null; 20 | const description = form.get('description') as string | null; 21 | 22 | const eventData = { 23 | name: name || '', 24 | date: date ? new Date(date) : new Date(), // TODO: make DB supports null too? 25 | description: description || '', 26 | groupId: params.groupId || '', 27 | }; 28 | 29 | if (!eventData.groupId) { 30 | throw Error('Group ID not Provided.'); 31 | } 32 | await db.event.create({ data: eventData }); 33 | 34 | return redirect(`/groups/${eventData.groupId}`); 35 | } 36 | 37 | export default function Page() { 38 | return ( 39 |
40 |
41 |
42 |

Create New Event

43 | 44 | 45 |
46 | 47 | 48 |