├── .dockerignore ├── .env.sample ├── .eslintrc.js ├── .gitignore ├── .map.ts ├── .npmrc ├── Dockerfile ├── LICENSE ├── README.md ├── components.json ├── content └── docs │ ├── deployment │ ├── index.mdx │ └── meta.json │ ├── hello.mdx │ ├── index.mdx │ ├── meta.json │ └── test.mdx ├── docker-compose.yml ├── drizzle.config.ts ├── drizzle ├── 0000_cute_kree.sql ├── 0001_last_the_fallen.sql ├── 0002_minor_dragon_man.sql ├── 0003_sad_shadow_king.sql ├── meta │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ ├── 0002_snapshot.json │ ├── 0003_snapshot.json │ └── _journal.json └── migrate │ ├── .gitignore │ ├── migrate.ts │ ├── package-lock.json │ └── package.json ├── mdx-components.tsx ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── bg.jpg ├── computer.jpeg ├── empty-state │ ├── mountain.svg │ ├── no-data.svg │ └── posts.svg ├── favicon.ico ├── group.jpeg ├── next.svg ├── plus.svg ├── profile.png ├── robots.txt ├── sitemap.xml ├── so-black.png ├── so-white.png ├── starterkitcard.png ├── vercel.svg └── wdc.jpeg ├── run.sh ├── source.config.ts ├── src ├── app-config.tsx ├── app │ ├── (docs) │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── layout.config.tsx │ │ └── source.ts │ ├── (main) │ │ ├── (auth) │ │ │ ├── reset-password │ │ │ │ ├── actions.ts │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── actions.ts │ │ │ │ ├── email │ │ │ │ │ ├── actions.ts │ │ │ │ │ └── page.tsx │ │ │ │ ├── forgot-password │ │ │ │ │ ├── actions.ts │ │ │ │ │ └── page.tsx │ │ │ │ ├── magic-link-form.tsx │ │ │ │ ├── magic │ │ │ │ │ ├── error │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ └── sign-up │ │ │ │ ├── actions.ts │ │ │ │ └── page.tsx │ │ ├── (coming-soon) │ │ │ ├── actions.tsx │ │ │ ├── coming-soon.tsx │ │ │ ├── footer.tsx │ │ │ ├── header.tsx │ │ │ └── newsletter-form.tsx │ │ ├── (landing) │ │ │ ├── _sections │ │ │ │ ├── credability.tsx │ │ │ │ ├── demo.tsx │ │ │ │ ├── faq.tsx │ │ │ │ ├── features.tsx │ │ │ │ ├── hero.tsx │ │ │ │ ├── newsletter.tsx │ │ │ │ ├── pricing.tsx │ │ │ │ ├── showcase.tsx │ │ │ │ ├── stack.tsx │ │ │ │ ├── testimonals.tsx │ │ │ │ └── the-problem.tsx │ │ │ └── page.tsx │ │ ├── (legal) │ │ │ ├── layout.tsx │ │ │ ├── privacy │ │ │ │ └── page.mdx │ │ │ └── terms-of-service │ │ │ │ └── page.mdx │ │ ├── (subscribe) │ │ │ ├── cancel │ │ │ │ └── page.tsx │ │ │ └── success │ │ │ │ └── page.tsx │ │ ├── _header │ │ │ ├── actions.tsx │ │ │ ├── header-actions-fallback.tsx │ │ │ ├── header-links.tsx │ │ │ ├── header-logo.tsx │ │ │ ├── header.tsx │ │ │ ├── menu-button.tsx │ │ │ ├── notifications.tsx │ │ │ └── sign-out-item.tsx │ │ ├── api │ │ │ ├── groups │ │ │ │ └── [groupId] │ │ │ │ │ └── images │ │ │ │ │ └── [imageId] │ │ │ │ │ └── route.ts │ │ │ ├── login │ │ │ │ ├── github │ │ │ │ │ ├── callback │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── google │ │ │ │ │ ├── callback │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── magic │ │ │ │ │ └── route.ts │ │ │ │ └── verify-email │ │ │ │ │ └── route.ts │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ ├── streams.ts │ │ │ ├── users │ │ │ │ └── [userId] │ │ │ │ │ └── images │ │ │ │ │ └── [imageId] │ │ │ │ │ └── route.ts │ │ │ └── webhooks │ │ │ │ └── stripe │ │ │ │ └── route.ts │ │ ├── browse │ │ │ ├── page.tsx │ │ │ └── pagination.tsx │ │ ├── conditional-header.tsx │ │ ├── dashboard │ │ │ ├── actions.tsx │ │ │ ├── create-group-button.tsx │ │ │ ├── create-group-form.tsx │ │ │ ├── group-card.tsx │ │ │ ├── groups │ │ │ │ └── [groupId] │ │ │ │ │ ├── [...not-found] │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── danger │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── delete-group-button.tsx │ │ │ │ │ ├── error.tsx │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── events │ │ │ │ │ ├── actions.tsx │ │ │ │ │ ├── create-event-button.tsx │ │ │ │ │ ├── create-event-form.tsx │ │ │ │ │ ├── edit-event-form.tsx │ │ │ │ │ ├── event-card-actions.tsx │ │ │ │ │ ├── event-card.tsx │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── group-header.tsx │ │ │ │ │ ├── info │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── edit-group-info-form.tsx │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── invite-button.tsx │ │ │ │ │ ├── join-group-button.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── leave-group-button.tsx │ │ │ │ │ ├── members │ │ │ │ │ ├── actions.tsx │ │ │ │ │ ├── member-card-actions.tsx │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── not-found.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── posts │ │ │ │ │ ├── [postId] │ │ │ │ │ │ ├── actions.ts │ │ │ │ │ │ ├── edit-post-form.tsx │ │ │ │ │ │ ├── edit-reply-form.tsx │ │ │ │ │ │ ├── loaders.ts │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── post-reply-form.tsx │ │ │ │ │ │ └── reply-actions.tsx │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── create-post-button.tsx │ │ │ │ │ ├── create-post-form.tsx │ │ │ │ │ ├── delete-post-button.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── post-card.tsx │ │ │ │ │ ├── settings │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── banner-upload-form.tsx │ │ │ │ │ ├── error.tsx │ │ │ │ │ ├── group-description-form.tsx │ │ │ │ │ ├── group-name-form.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── social-links-form.tsx │ │ │ │ │ ├── util.ts │ │ │ │ │ └── visibility-switch.tsx │ │ │ │ │ ├── tabs-section.tsx │ │ │ │ │ └── utils.ts │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ ├── settings │ │ │ │ ├── danger │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── delete-account-button.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── profile │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── edit-bio-form.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── profile-image-form.tsx │ │ │ │ │ ├── profile-image.tsx │ │ │ │ │ ├── profile-name-form.tsx │ │ │ │ │ └── profile-name.tsx │ │ │ │ ├── security │ │ │ │ │ ├── actions.ts │ │ │ │ │ ├── logout-all-devices-button.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── subscription │ │ │ │ │ └── page.tsx │ │ │ │ └── tabs-section.tsx │ │ │ └── validation.tsx │ │ ├── error.tsx │ │ ├── invites │ │ │ └── [token] │ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── maintenance.tsx │ │ ├── not-found.tsx │ │ ├── notifications │ │ │ ├── actions.ts │ │ │ ├── clear-read-button.tsx │ │ │ ├── mark-read-button.tsx │ │ │ ├── page.tsx │ │ │ └── view-button.tsx │ │ ├── signed-out │ │ │ └── page.tsx │ │ ├── users │ │ │ └── [userId] │ │ │ │ ├── actions.ts │ │ │ │ ├── follow-button.tsx │ │ │ │ ├── followers │ │ │ │ └── page.tsx │ │ │ │ ├── groups │ │ │ │ └── page.tsx │ │ │ │ ├── info │ │ │ │ ├── bio-view.tsx │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── posts │ │ │ │ ├── page.tsx │ │ │ │ └── user-post-card.tsx │ │ │ │ ├── profile-header.tsx │ │ │ │ ├── profile-tabs.tsx │ │ │ │ └── unfollow-button.tsx │ │ ├── util.ts │ │ └── verify-success │ │ │ └── page.tsx │ ├── globals.css │ └── layout.tsx ├── auth.ts ├── components │ ├── auth.tsx │ ├── breakpoint-overlay.tsx │ ├── confetti.tsx │ ├── configuration-panel.tsx │ ├── container.tsx │ ├── delete-modal.tsx │ ├── footer.tsx │ ├── form-group.tsx │ ├── icons.tsx │ ├── input-error.tsx │ ├── interactive-overlay.tsx │ ├── loader-button.tsx │ ├── mdx.tsx │ ├── mode-toggle.tsx │ ├── page-header.tsx │ ├── posthog-page-view.tsx │ ├── stripe │ │ └── upgrade-button │ │ │ ├── actions.ts │ │ │ └── checkout-button.tsx │ ├── submit-button.tsx │ └── ui │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── switch.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── time-period-select.tsx │ │ ├── time-picker-input.tsx │ │ ├── time-picker-utils.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts ├── data-access │ ├── accounts.ts │ ├── events.ts │ ├── following.ts │ ├── groups.ts │ ├── invites.ts │ ├── magic-links.ts │ ├── membership.ts │ ├── newsletters.ts │ ├── notifications.ts │ ├── posts.ts │ ├── profiles.ts │ ├── replies.ts │ ├── reset-tokens.ts │ ├── sessions.ts │ ├── subscriptions.ts │ ├── users.ts │ ├── utils.ts │ └── verify-email.ts ├── db │ ├── clear.ts │ ├── index.ts │ ├── migrate.ts │ ├── schema.ts │ └── seed.ts ├── emails │ ├── invite.tsx │ ├── magic-link.tsx │ ├── reset-password.tsx │ └── verify-email.tsx ├── env.ts ├── hooks │ └── use-media-query.tsx ├── lib │ ├── errors.ts │ ├── files.ts │ ├── get-ip.ts │ ├── limiter.ts │ ├── newsletter.ts │ ├── params.ts │ ├── safe-action.ts │ ├── send-email.tsx │ ├── session.ts │ ├── source.ts │ ├── stripe.ts │ └── utils.ts ├── middleware.ts ├── providers │ ├── providers.tsx │ └── theme-provider.tsx ├── styles │ ├── common.tsx │ └── icons.tsx ├── use-cases │ ├── accounts.ts │ ├── authorization.ts │ ├── errors.ts │ ├── events.ts │ ├── files.ts │ ├── following.ts │ ├── groups.ts │ ├── invites.tsx │ ├── magic-link.tsx │ ├── membership.ts │ ├── newsletter.ts │ ├── notifications.ts │ ├── posts.ts │ ├── replies.ts │ ├── sessions.ts │ ├── subscriptions.ts │ ├── types.ts │ ├── users.tsx │ └── util.ts └── util │ ├── date.ts │ ├── notifications.tsx │ ├── subscriptions.ts │ ├── util.ts │ ├── uuid.ts │ └── zsa-mapper.ts ├── tailwind.config.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .git/ 3 | .next/ -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # GENERAL 2 | DATABASE_URL="postgresql://postgres:example@localhost:5432/postgres" 3 | HOST_NAME="http://localhost:3000" 4 | 5 | # GOOGLE OAUTH 6 | GOOGLE_CLIENT_ID="replace_me" 7 | GOOGLE_CLIENT_SECRET="replace_me" 8 | 9 | # GITHUB OAUTH 10 | GITHUB_CLIENT_SECRET=replace_me 11 | GITHUB_CLIENT_ID=replace_me 12 | 13 | # STRIPE 14 | STRIPE_API_KEY="replace_me" 15 | STRIPE_WEBHOOK_SECRET="replace_me" 16 | NEXT_PUBLIC_STRIPE_KEY="replace_me" 17 | NEXT_PUBLIC_STRIPE_MANAGE_URL="replace_me" 18 | NEXT_PUBLIC_PRICE_ID_BASIC="replace_me" 19 | NEXT_PUBLIC_PRICE_ID_PREMIUM="replace_me" 20 | 21 | # RESEND 22 | EMAIL_SERVER_USER=resend 23 | EMAIL_SERVER_PASSWORD=replace_me 24 | EMAIL_SERVER_HOST=smtp.resend.com 25 | EMAIL_SERVER_PORT=465 26 | EMAIL_FROM="Group Finder " 27 | RESEND_AUDIENCE_ID=replace_me 28 | 29 | # CLOUDFLARE 30 | CLOUDFLARE_ACCOUNT_ID=replace_me 31 | CLOUDFLARE_ACCESS_KEY_ID=replace_me 32 | CLOUDFLARE_SECRET_ACCESS_KEY=replace_me 33 | CLOUDFLARE_BUCKET_NAME=replace_me 34 | 35 | # POSTHOG 36 | NEXT_PUBLIC_POSTHOG_KEY=replace_me 37 | NEXT_PUBLIC_POSTHOG_HOST=replace_me 38 | 39 | # used to show breakpoint overlay in development 40 | NEXT_PUBLIC_IS_LOCAL=true 41 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | root: true, 4 | parser: "@typescript-eslint/parser", 5 | parserOptions: { 6 | project: true, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | 39 | .env 40 | 41 | local.db 42 | 43 | .source -------------------------------------------------------------------------------- /.map.ts: -------------------------------------------------------------------------------- 1 | /** Auto-generated **/ 2 | declare const map: Record 3 | 4 | export { map } -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/.npmrc -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-slim AS base 2 | 3 | FROM base AS builder 4 | 5 | WORKDIR /app 6 | 7 | COPY package.json package-lock.json* ./ 8 | RUN npm ci 9 | COPY . . 10 | 11 | ENV NEXT_TELEMETRY_DISABLED=1 12 | ENV NODE_ENV=production 13 | 14 | ARG DATABASE_URL 15 | ARG HOST_NAME 16 | ARG GOOGLE_CLIENT_ID 17 | ARG GOOGLE_CLIENT_SECRET 18 | ARG GITHUB_CLIENT_SECRET 19 | ARG GITHUB_CLIENT_ID 20 | ARG STRIPE_API_KEY 21 | ARG STRIPE_WEBHOOK_SECRET 22 | ARG NEXT_PUBLIC_STRIPE_KEY 23 | ARG NEXT_PUBLIC_STRIPE_MANAGE_URL 24 | ARG NEXT_PUBLIC_PRICE_ID_BASIC 25 | ARG NEXT_PUBLIC_PRICE_ID_PREMIUM 26 | ARG EMAIL_SERVER_USER 27 | ARG EMAIL_SERVER_PASSWORD 28 | ARG EMAIL_SERVER_HOST 29 | ARG EMAIL_SERVER_PORT 30 | ARG EMAIL_FROM 31 | ARG RESEND_AUDIENCE_ID 32 | ARG CLOUDFLARE_ACCOUNT_ID 33 | ARG CLOUDFLARE_ACCESS_KEY_ID 34 | ARG CLOUDFLARE_SECRET_ACCESS_KEY 35 | ARG CLOUDFLARE_BUCKET_NAME 36 | ARG NEXT_PUBLIC_POSTHOG_KEY 37 | ARG NEXT_PUBLIC_POSTHOG_HOST 38 | 39 | RUN npm run build 40 | 41 | FROM base AS runner 42 | WORKDIR /app 43 | 44 | ENV NEXT_TELEMETRY_DISABLED=1 45 | ENV NODE_ENV=production 46 | 47 | RUN addgroup --system --gid 1001 nodejs 48 | RUN adduser --system --uid 1001 nextjs 49 | 50 | COPY --from=builder /app/public ./public 51 | 52 | RUN mkdir .next 53 | RUN chown nextjs:nodejs .next 54 | 55 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 56 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 57 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 58 | COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle 59 | COPY --from=builder --chown=nextjs:nodejs /app/run.sh ./run.sh 60 | 61 | RUN cd drizzle/migrate && npm i 62 | 63 | WORKDIR /app 64 | 65 | USER nextjs 66 | 67 | EXPOSE 3000 68 | 69 | ENV PORT=3000 70 | 71 | ARG HOSTNAME 72 | 73 | CMD ./run.sh -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Web Dev Cody 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /content/docs/deployment/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home Page 3 | description: Your first document 4 | --- 5 | 6 | Welcome to the docs! You can start writing documents in `/content/docs`. 7 | 8 | ## What is Next? 9 | 10 | 11 | 12 | 16 | 17 | -------------------------------------------------------------------------------- /content/docs/deployment/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Deployments", 3 | "pages": ["index"] 4 | } 5 | -------------------------------------------------------------------------------- /content/docs/hello.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hello 3 | description: Some Test Page 4 | --- 5 | 6 | # Hello World 7 | 8 | 1. This is a test page 9 | 2. This is a test page 10 | 3. This is a test pizza hut 11 | 12 | ```typescript 13 | console.log("Hello World"); 14 | ``` 15 | -------------------------------------------------------------------------------- /content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home Page 3 | description: Your first document 4 | --- 5 | 6 | Welcome to the docs! You can start writing documents in `/content/docs`. 7 | 8 | ## What is Next? 9 | 10 | 11 | 12 | 16 | 17 | -------------------------------------------------------------------------------- /content/docs/meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Name of Folder", 3 | "pages": ["hello", "deployment", "test", "index"] 4 | } 5 | -------------------------------------------------------------------------------- /content/docs/test.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Testing 3 | description: Components 4 | --- 5 | 6 | ## Code Block 7 | 8 | ```js 9 | console.log("Hello World"); 10 | ``` 11 | 12 | ## Cards 13 | 14 | 15 | 16 | 20 | 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | wdc-saas-starter-kit: 4 | image: postgres 5 | restart: always 6 | container_name: wdc-saas-starter-kit 7 | ports: 8 | - 5432:5432 9 | environment: 10 | POSTGRES_PASSWORD: example 11 | PGDATA: /data/postgres 12 | volumes: 13 | - postgres:/data/postgres 14 | 15 | volumes: 16 | postgres: 17 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import { defineConfig } from "drizzle-kit"; 3 | 4 | export default defineConfig({ 5 | schema: "./src/db/schema.ts", 6 | dialect: "postgresql", 7 | out: "./drizzle", 8 | dbCredentials: { 9 | url: env.DATABASE_URL, 10 | }, 11 | verbose: true, 12 | strict: true, 13 | }); 14 | -------------------------------------------------------------------------------- /drizzle/0001_last_the_fallen.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX IF NOT EXISTS "user_id_account_type_idx" ON "gf_accounts" ("userId","accountType");--> statement-breakpoint 2 | CREATE INDEX IF NOT EXISTS "following_user_id_foreign_user_id_idx" ON "gf_following" ("userId","foreignUserId");--> statement-breakpoint 3 | CREATE INDEX IF NOT EXISTS "groups_user_id_is_public_idx" ON "gf_group" ("userId","isPublic");--> statement-breakpoint 4 | CREATE INDEX IF NOT EXISTS "magic_links_token_idx" ON "gf_magic_links" ("token");--> statement-breakpoint 5 | CREATE INDEX IF NOT EXISTS "memberships_user_id_group_id_idx" ON "gf_membership" ("userId","groupId");--> statement-breakpoint 6 | CREATE INDEX IF NOT EXISTS "replies_post_id_idx" ON "gf_replies" ("postId");--> statement-breakpoint 7 | CREATE INDEX IF NOT EXISTS "reset_tokens_token_idx" ON "gf_reset_tokens" ("token");--> statement-breakpoint 8 | CREATE INDEX IF NOT EXISTS "sessions_user_id_idx" ON "gf_session" ("userId");--> statement-breakpoint 9 | CREATE INDEX IF NOT EXISTS "subscriptions_stripe_subscription_id_idx" ON "gf_subscriptions" ("stripeSubscriptionId");--> statement-breakpoint 10 | CREATE INDEX IF NOT EXISTS "verify_email_tokens_token_idx" ON "gf_verify_email_tokens" ("token");--> statement-breakpoint 11 | ALTER TABLE "gf_subscriptions" ADD CONSTRAINT "gf_subscriptions_userId_unique" UNIQUE("userId"); -------------------------------------------------------------------------------- /drizzle/0002_minor_dragon_man.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "gf_invites" ADD COLUMN "tokenExpiresAt" timestamp; -------------------------------------------------------------------------------- /drizzle/0003_sad_shadow_king.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "gf_invites" ALTER COLUMN "tokenExpiresAt" SET NOT NULL; -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1720313634592, 9 | "tag": "0000_cute_kree", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "6", 15 | "when": 1728785999108, 16 | "tag": "0001_last_the_fallen", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "6", 22 | "when": 1728786587918, 23 | "tag": "0002_minor_dragon_man", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "6", 29 | "when": 1729521918618, 30 | "tag": "0003_sad_shadow_king", 31 | "breakpoints": true 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /drizzle/migrate/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /drizzle/migrate/migrate.ts: -------------------------------------------------------------------------------- 1 | // This file is to get the migration to run in the Dockerfile right 2 | // before the service runs. 3 | 4 | import "dotenv/config"; 5 | import { drizzle } from "drizzle-orm/postgres-js"; 6 | import postgres from "postgres"; 7 | import { migrate } from "drizzle-orm/postgres-js/migrator"; 8 | 9 | const pg = postgres(process.env.DATABASE_URL!); 10 | const database = drizzle(pg); 11 | 12 | async function main() { 13 | await migrate(database, { migrationsFolder: ".." }); 14 | await pg.end(); 15 | } 16 | 17 | main(); 18 | -------------------------------------------------------------------------------- /drizzle/migrate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Used for installing and running the migration on the Dockerfile runner.", 3 | "license": "MIT", 4 | "scripts": { 5 | "db:migrate": "tsx ./migrate.ts" 6 | }, 7 | "dependencies": { 8 | "dotenv": "16.4.5", 9 | "drizzle-orm": "0.32.1", 10 | "postgres": "3.4.4", 11 | "tsx": "^4.16.2", 12 | "typescript": "5.5.4" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import type { MDXComponents } from "mdx/types"; 2 | import defaultComponents from "fumadocs-ui/mdx"; 3 | 4 | export function useMDXComponents(components: MDXComponents): MDXComponents { 5 | return { 6 | ...defaultComponents, 7 | ...components, 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import { createMDX } from "fumadocs-mdx/next"; 2 | 3 | const withMDX = createMDX(); 4 | 5 | /** @type {import('next').NextConfig} */ 6 | const nextConfig = { 7 | output: "standalone", 8 | reactStrictMode: true, 9 | serverExternalPackages: ["@aws-sdk/s3-request-presigner"], 10 | images: { 11 | remotePatterns: [ 12 | { 13 | protocol: "https", 14 | hostname: "*.googleusercontent.com", 15 | port: "", 16 | pathname: "**", 17 | }, 18 | { 19 | protocol: "http", 20 | hostname: "localhost", 21 | port: "3000", 22 | pathname: "**", 23 | }, 24 | { 25 | protocol: "https", 26 | hostname: "avatars.githubusercontent.com", 27 | port: "", 28 | pathname: "**", 29 | }, 30 | ], 31 | }, 32 | }; 33 | 34 | export default withMDX(nextConfig); 35 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/public/bg.jpg -------------------------------------------------------------------------------- /public/computer.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/public/computer.jpeg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/public/favicon.ico -------------------------------------------------------------------------------- /public/group.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/public/group.jpeg -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/public/profile.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://wdcstarterkit.com/ 5 | weekly 6 | 1.0 7 | 8 | 9 | https://wdcstarterkit.com/terms-of-service 10 | weekly 11 | 0.8 12 | 13 | 14 | https://wdcstarterkit.com/privacy 15 | weekly 16 | 0.4 17 | 18 | -------------------------------------------------------------------------------- /public/so-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/public/so-black.png -------------------------------------------------------------------------------- /public/so-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/public/so-white.png -------------------------------------------------------------------------------- /public/starterkitcard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/public/starterkitcard.png -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/wdc.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/public/wdc.jpeg -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | pushd ./drizzle/migrate 4 | npm run db:migrate 5 | popd 6 | 7 | node server.js 8 | -------------------------------------------------------------------------------- /source.config.ts: -------------------------------------------------------------------------------- 1 | import { defineDocs, defineConfig } from "fumadocs-mdx/config"; 2 | 3 | export const { docs, meta } = defineDocs({ 4 | dir: "content/docs", 5 | }); 6 | 7 | export default defineConfig(); 8 | -------------------------------------------------------------------------------- /src/app-config.tsx: -------------------------------------------------------------------------------- 1 | export const appConfig: { 2 | mode: "comingSoon" | "maintenance" | "live"; 3 | } = { 4 | mode: "live", 5 | }; 6 | 7 | export const protectedRoutes = ["/purchases", "/dashboard"]; 8 | export const applicationName = "Group Finder"; 9 | export const companyName = "Groupie, LLC"; 10 | 11 | export const MAX_UPLOAD_IMAGE_SIZE_IN_MB = 5; 12 | export const MAX_UPLOAD_IMAGE_SIZE = 1024 * 1024 * MAX_UPLOAD_IMAGE_SIZE_IN_MB; 13 | 14 | export const TOKEN_LENGTH = 32; 15 | export const TOKEN_TTL = 1000 * 60 * 5; // 5 min 16 | export const VERIFY_EMAIL_TTL = 1000 * 60 * 60 * 24 * 7; // 7 days 17 | 18 | export const MAX_GROUP_LIMIT = 10; 19 | export const MAX_GROUP_PREMIUM_LIMIT = 50; 20 | 21 | export const afterLoginUrl = "/dashboard"; 22 | -------------------------------------------------------------------------------- /src/app/(docs)/docs/[[...slug]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { source } from "@/lib/source"; 2 | import { 3 | DocsPage, 4 | DocsBody, 5 | DocsDescription, 6 | DocsTitle, 7 | } from "fumadocs-ui/page"; 8 | import { notFound } from "next/navigation"; 9 | import defaultMdxComponents from "fumadocs-ui/mdx"; 10 | 11 | export default async function Page(props: { 12 | params: Promise<{ slug?: string[] }>; 13 | }) { 14 | const params = await props.params; 15 | const page = source.getPage(params.slug); 16 | if (!page) notFound(); 17 | 18 | const MDX = page.data.body; 19 | 20 | return ( 21 | 22 | {page.data.title} 23 | {page.data.description} 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | export async function generateStaticParams() { 32 | return source.generateParams(); 33 | } 34 | 35 | export async function generateMetadata(props: { 36 | params: Promise<{ slug?: string[] }>; 37 | }) { 38 | const params = await props.params; 39 | const page = source.getPage(params.slug); 40 | if (!page) notFound(); 41 | 42 | return { 43 | title: page.data.title, 44 | description: page.data.description, 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/app/(docs)/docs/layout.tsx: -------------------------------------------------------------------------------- 1 | import { DocsLayout } from "fumadocs-ui/layouts/docs"; 2 | import type { ReactNode } from "react"; 3 | import { baseOptions } from "../layout.config"; 4 | import { source } from "@/lib/source"; 5 | 6 | export default function DocumentationLayout({ 7 | children, 8 | }: { 9 | children: ReactNode; 10 | }) { 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(docs)/layout.config.tsx: -------------------------------------------------------------------------------- 1 | import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; 2 | 3 | /** 4 | * Shared layout configurations 5 | * 6 | * you can configure layouts individually from: 7 | * Home Layout: app/(home)/layout.tsx 8 | * Docs Layout: app/docs/layout.tsx 9 | */ 10 | export const baseOptions: BaseLayoutProps = { 11 | nav: { 12 | title: "My App", 13 | }, 14 | links: [ 15 | { 16 | text: "Documentation", 17 | url: "/docs", 18 | active: "nested-url", 19 | }, 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /src/app/(docs)/source.ts: -------------------------------------------------------------------------------- 1 | import { createMDXSource } from "fumadocs-mdx"; 2 | import { loader } from "fumadocs-core/source"; 3 | import { docs, meta } from "../../../.source"; 4 | 5 | export const { getPage, getPages, pageTree } = loader({ 6 | baseUrl: "/docs", 7 | rootDir: "docs", 8 | source: createMDXSource(docs, meta), 9 | }); 10 | -------------------------------------------------------------------------------- /src/app/(main)/(auth)/reset-password/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { rateLimitByIp } from "@/lib/limiter"; 4 | import { unauthenticatedAction } from "@/lib/safe-action"; 5 | import { changePasswordUseCase } from "@/use-cases/users"; 6 | import { z } from "zod"; 7 | 8 | export const changePasswordAction = unauthenticatedAction 9 | .createServerAction() 10 | .input( 11 | z.object({ 12 | token: z.string(), 13 | password: z.string().min(8), 14 | }) 15 | ) 16 | .handler(async ({ input: { token, password } }) => { 17 | await rateLimitByIp({ key: "change-password", limit: 2, window: 30000 }); 18 | await changePasswordUseCase(token, password); 19 | }); 20 | -------------------------------------------------------------------------------- /src/app/(main)/(auth)/sign-in/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { rateLimitByKey } from "@/lib/limiter"; 4 | import { unauthenticatedAction } from "@/lib/safe-action"; 5 | import { sendMagicLinkUseCase } from "@/use-cases/magic-link"; 6 | import { redirect } from "next/navigation"; 7 | import { z } from "zod"; 8 | 9 | export const signInMagicLinkAction = unauthenticatedAction 10 | .createServerAction() 11 | .input( 12 | z.object({ 13 | email: z.string().email(), 14 | }) 15 | ) 16 | .handler(async ({ input }) => { 17 | await rateLimitByKey({ key: input.email, limit: 1, window: 30000 }); 18 | await sendMagicLinkUseCase(input.email); 19 | redirect("/sign-in/magic"); 20 | }); 21 | -------------------------------------------------------------------------------- /src/app/(main)/(auth)/sign-in/email/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { afterLoginUrl } from "@/app-config"; 4 | import { rateLimitByKey } from "@/lib/limiter"; 5 | import { unauthenticatedAction } from "@/lib/safe-action"; 6 | import { setSession } from "@/lib/session"; 7 | import { signInUseCase } from "@/use-cases/users"; 8 | import { redirect } from "next/navigation"; 9 | import { z } from "zod"; 10 | 11 | export const signInAction = unauthenticatedAction 12 | .createServerAction() 13 | .input( 14 | z.object({ 15 | email: z.string().email(), 16 | password: z.string().min(8), 17 | }) 18 | ) 19 | .handler(async ({ input }) => { 20 | await rateLimitByKey({ key: input.email, limit: 3, window: 10000 }); 21 | const user = await signInUseCase(input.email, input.password); 22 | await setSession(user.id); 23 | redirect(afterLoginUrl); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/(main)/(auth)/sign-in/forgot-password/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { resetPasswordUseCase } from "@/use-cases/users"; 4 | import { unauthenticatedAction } from "@/lib/safe-action"; 5 | import { z } from "zod"; 6 | import { rateLimitByKey } from "@/lib/limiter"; 7 | 8 | export const resetPasswordAction = unauthenticatedAction 9 | .createServerAction() 10 | .input( 11 | z.object({ 12 | email: z.string().email(), 13 | }) 14 | ) 15 | .handler(async ({ input }) => { 16 | await rateLimitByKey({ key: input.email, limit: 1, window: 30000 }); 17 | await resetPasswordUseCase(input.email); 18 | }); 19 | -------------------------------------------------------------------------------- /src/app/(main)/(auth)/sign-in/magic-link-form.tsx: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { Input } from "@/components/ui/input"; 4 | import { zodResolver } from "@hookform/resolvers/zod"; 5 | import { useForm } from "react-hook-form"; 6 | import { 7 | Form, 8 | FormControl, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage, 13 | } from "@/components/ui/form"; 14 | import { signInMagicLinkAction } from "./actions"; 15 | import { LoaderButton } from "@/components/loader-button"; 16 | import { useServerAction } from "zsa-react"; 17 | import { useToast } from "@/components/ui/use-toast"; 18 | 19 | const magicLinkSchema = z.object({ 20 | email: z.string().email(), 21 | }); 22 | 23 | export function MagicLinkForm() { 24 | const { toast } = useToast(); 25 | 26 | const { execute, isPending } = useServerAction(signInMagicLinkAction, { 27 | onError({ err }) { 28 | toast({ 29 | title: "Something went wrong", 30 | description: err.message, 31 | variant: "destructive", 32 | }); 33 | }, 34 | }); 35 | 36 | const form = useForm>({ 37 | resolver: zodResolver(magicLinkSchema), 38 | defaultValues: { 39 | email: "", 40 | }, 41 | }); 42 | 43 | function onSubmit(values: z.infer) { 44 | execute(values); 45 | } 46 | 47 | return ( 48 |
49 | 50 | ( 54 | 55 | Email 56 | 57 | 63 | 64 | 65 | 66 | )} 67 | /> 68 | 69 | Sign in with magic link 70 | 71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/app/(main)/(auth)/sign-in/magic/error/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { pageTitleStyles } from "@/styles/common"; 3 | import Link from "next/link"; 4 | 5 | export default function MagicLinkPage() { 6 | return ( 7 |
8 |

Expired Token

9 |

10 | Sorry, this token was either expired or already used. Please try logging 11 | in again 12 |

13 | 14 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(main)/(auth)/sign-in/magic/page.tsx: -------------------------------------------------------------------------------- 1 | import { pageTitleStyles } from "@/styles/common"; 2 | 3 | export default function MagicLinkPage() { 4 | return ( 5 |
6 |

Check your email

7 |

8 | We sent you a magic link to sign in. Click the link in your email to 9 | sign in. 10 |

11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/app/(main)/(auth)/sign-up/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { afterLoginUrl } from "@/app-config"; 4 | import { rateLimitByIp, rateLimitByKey } from "@/lib/limiter"; 5 | import { unauthenticatedAction } from "@/lib/safe-action"; 6 | import { setSession } from "@/lib/session"; 7 | import { registerUserUseCase } from "@/use-cases/users"; 8 | import { redirect } from "next/navigation"; 9 | import { z } from "zod"; 10 | 11 | export const signUpAction = unauthenticatedAction 12 | .createServerAction() 13 | .input( 14 | z.object({ 15 | email: z.string().email(), 16 | password: z.string().min(8), 17 | }) 18 | ) 19 | .handler(async ({ input }) => { 20 | await rateLimitByIp({ key: "register", limit: 3, window: 30000 }); 21 | const user = await registerUserUseCase(input.email, input.password); 22 | await setSession(user.id); 23 | return redirect(afterLoginUrl); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/(main)/(coming-soon)/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { z } from "zod"; 4 | import { unauthenticatedAction } from "@/lib/safe-action"; 5 | import { rateLimitByIp } from "@/lib/limiter"; 6 | import { subscribeEmailUseCase } from "@/use-cases/newsletter"; 7 | 8 | export const subscribeEmailAction = unauthenticatedAction 9 | .createServerAction() 10 | .input( 11 | z.object({ 12 | email: z.string().email(), 13 | }) 14 | ) 15 | .handler(async ({ input: { email } }) => { 16 | await rateLimitByIp({ key: "newsletter" }); 17 | await subscribeEmailUseCase(email); 18 | }); 19 | -------------------------------------------------------------------------------- /src/app/(main)/(coming-soon)/footer.tsx: -------------------------------------------------------------------------------- 1 | import { Lines } from "@/app/(main)/(coming-soon)/coming-soon"; 2 | import { 3 | DiscordIcon, 4 | GithubIcon, 5 | XIcon, 6 | YoutubeIcon, 7 | } from "@/components/icons"; 8 | import Link from "next/link"; 9 | 10 | export function ComingSoonFooter() { 11 | return ( 12 |
13 | 14 | 15 |
16 |
Follow for updates!
17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | 33 |
34 | 35 | Privacy Policy 36 | 37 | 38 | Terms of Service 39 | 40 |
41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/(main)/(coming-soon)/header.tsx: -------------------------------------------------------------------------------- 1 | import { ModeToggle } from "@/components/mode-toggle"; 2 | import { applicationName } from "@/app-config"; 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | 6 | export function ComingSoonHeader() { 7 | return ( 8 |
9 |
10 |
11 | 15 | hero image 22 |
23 |
Coming Soon...
24 |
{applicationName}
25 |
26 | 27 |
28 | 29 | 30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/app/(main)/(landing)/_sections/credability.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/src/app/(main)/(landing)/_sections/credability.tsx -------------------------------------------------------------------------------- /src/app/(main)/(landing)/_sections/demo.tsx: -------------------------------------------------------------------------------- 1 | export function DemoSection() { 2 | return ( 3 |
4 |
5 |

6 | But wait... you don't know how to use half the tools I just 7 | mentioned? 8 |

9 | 10 |

11 | Don't worry, I have experience making over 600 tutorial videos on 12 | youtube and this starter kit includes documentation and video 13 | walkthroughs. I'll teach you how to: 14 |

15 | 16 |
    17 |
  • how to run the app locally
  • 18 |
  • how to navigate the codebase
  • 19 |
  • how to deploy your application
  • 20 |
  • how to setup the third party services
  • 21 |
  • how to register and setup a domain
  • 22 |
  • how to monitor your production application
  • 23 |
  • how to add new features
  • 24 |
  • and more!
  • 25 |
26 | 27 |
28 | 36 |
37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/app/(main)/(landing)/_sections/faq.tsx: -------------------------------------------------------------------------------- 1 | export function FaqSection() { 2 | return
; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/(main)/(landing)/_sections/hero.tsx: -------------------------------------------------------------------------------- 1 | import { SignedIn } from "@/components/auth"; 2 | import { SignedOut } from "@/components/auth"; 3 | import Container from "@/components/container"; 4 | import { Badge } from "@/components/ui/badge"; 5 | import { Button } from "@/components/ui/button"; 6 | import Image from "next/image"; 7 | import Link from "next/link"; 8 | 9 | export function HeroSection() { 10 | return ( 11 | <> 12 | 13 |
14 |
15 | 16 | Discover like-minded individuals 17 | 18 |

19 | Create and Discover New Hobby Groups 20 |

21 |

22 | Our online service makes it easy to connect with others who share 23 | your interests, whether it's hiking, painting, or playing soccer. 24 | Create or join a group, schedule meetups, and enjoy pursuing your 25 | passions with new friends by your side. Start building your 26 | community today! 27 |

28 |
29 | 30 | 33 | 34 | 35 | 36 | 39 | 40 |
41 |
42 | hero image 49 |
50 |
51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/app/(main)/(landing)/_sections/newsletter.tsx: -------------------------------------------------------------------------------- 1 | export function NewsletterSection() { 2 | return ( 3 |
4 |
{ 6 | "use server"; 7 | }} 8 | > 9 |
10 | 11 | 12 |
13 | 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/app/(main)/(landing)/_sections/showcase.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/src/app/(main)/(landing)/_sections/showcase.tsx -------------------------------------------------------------------------------- /src/app/(main)/(landing)/_sections/stack.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/wdc-saas-starter-kit/a4d864dad1db876ca8c452df6afa1a7f138cf9b0/src/app/(main)/(landing)/_sections/stack.tsx -------------------------------------------------------------------------------- /src/app/(main)/(landing)/_sections/testimonals.tsx: -------------------------------------------------------------------------------- 1 | export function TestimonalsSection() { 2 | return
; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/(main)/(landing)/page.tsx: -------------------------------------------------------------------------------- 1 | import { ComingSoon } from "@/app/(main)/(coming-soon)/coming-soon"; 2 | import { DemoSection } from "@/app/(main)/(landing)/_sections/demo"; 3 | import { FaqSection } from "@/app/(main)/(landing)/_sections/faq"; 4 | import { FeaturesSection } from "@/app/(main)/(landing)/_sections/features"; 5 | import { HeroSection } from "@/app/(main)/(landing)/_sections/hero"; 6 | import { NewsletterSection } from "@/app/(main)/(landing)/_sections/newsletter"; 7 | import { PricingSection } from "@/app/(main)/(landing)/_sections/pricing"; 8 | import { TestimonalsSection } from "@/app/(main)/(landing)/_sections/testimonals"; 9 | import { TheProblemSection } from "@/app/(main)/(landing)/_sections/the-problem"; 10 | 11 | import { appConfig } from "@/app-config"; 12 | import { getUserPlanUseCase } from "@/use-cases/subscriptions"; 13 | import { getCurrentUser } from "@/lib/session"; 14 | 15 | export default async function Home() { 16 | if (appConfig.mode === "comingSoon") { 17 | return ; 18 | } 19 | 20 | if (appConfig.mode === "maintenance") { 21 | return ( 22 |
23 |

Maintenance

24 |
25 | ); 26 | } 27 | 28 | if (appConfig.mode === "live") { 29 | const user = await getCurrentUser(); 30 | const hasSubscription = user 31 | ? (await getUserPlanUseCase(user.id)) !== "free" 32 | : false; 33 | 34 | return ( 35 |
36 | 37 | 38 |
39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/(main)/(legal)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Lines } from "@/app/(main)/(coming-soon)/coming-soon"; 2 | import { ComingSoonHeader } from "@/app/(main)/(coming-soon)/header"; 3 | import { appConfig } from "@/app-config"; 4 | import { ReactNode } from "react"; 5 | 6 | export default function Layout({ children }: { children: ReactNode }) { 7 | return ( 8 |
9 | 10 | {appConfig.mode === "comingSoon" && } 11 |
12 | {children} 13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(main)/(subscribe)/cancel/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { ChevronLeft } from "lucide-react"; 3 | import Link from "next/link"; 4 | 5 | export default function SuccessPage() { 6 | return ( 7 | <> 8 |
9 |

Not interested? No worries at all

10 | 11 |

12 | Checkout a free starter kit below until you're ready for the 13 | premium starter kit 14 |

15 | 16 |

17 | 25 |

26 | 27 | 33 |
34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/app/(main)/(subscribe)/success/page.tsx: -------------------------------------------------------------------------------- 1 | import Confetti from "@/components/confetti"; 2 | import { Button } from "@/components/ui/button"; 3 | import Link from "next/link"; 4 | 5 | export default function SuccessPage() { 6 | return ( 7 | <> 8 |
9 |

You've been upgraded!

10 | 11 | 12 | 13 |

Click below to start using our service

14 | 15 | 18 |
19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/app/(main)/_header/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { invalidateSession, validateRequest } from "@/auth"; 4 | import { authenticatedAction } from "@/lib/safe-action"; 5 | import { markNotificationAsReadUseCase } from "@/use-cases/notifications"; 6 | import { revalidatePath } from "next/cache"; 7 | import { redirect } from "next/navigation"; 8 | import { z } from "zod"; 9 | 10 | export const markNotificationAsReadAction = authenticatedAction 11 | .createServerAction() 12 | .input( 13 | z.object({ 14 | notificationId: z.number(), 15 | }) 16 | ) 17 | .handler(async ({ input: { notificationId }, ctx: { user } }) => { 18 | await markNotificationAsReadUseCase(user, notificationId); 19 | revalidatePath(`/`, "layout"); 20 | }); 21 | 22 | export async function signOutAction() { 23 | const { session } = await validateRequest(); 24 | 25 | if (!session) { 26 | redirect("/sign-in"); 27 | } 28 | 29 | await invalidateSession(session.id); 30 | redirect("/signed-out"); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/(main)/_header/header-actions-fallback.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Loader2Icon } from "lucide-react"; 4 | 5 | export function HeaderActionsFallback() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(main)/_header/header-logo.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { applicationName } from "@/app-config"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | import { usePathname } from "next/navigation"; 7 | 8 | export function HeaderLogo() { 9 | const pathname = usePathname(); 10 | const isDashboard = pathname.startsWith("/dashboard"); 11 | 12 | return ( 13 | 17 | hero image 24 | {applicationName} 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/(main)/_header/sign-out-item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; 4 | import { LogOut } from "lucide-react"; 5 | import * as NProgress from "nprogress"; 6 | import { signOutAction } from "./actions"; 7 | 8 | export function SignOutItem() { 9 | return ( 10 | { 13 | NProgress.start(); 14 | signOutAction().then(() => { 15 | NProgress.done(); 16 | }); 17 | }} 18 | > 19 | 20 | Sign Out 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/app/(main)/api/groups/[groupId]/images/[imageId]/route.ts: -------------------------------------------------------------------------------- 1 | import { streamImageFromUrl } from "@/app/(main)/api/streams"; 2 | import { env } from "@/env"; 3 | import { getCurrentUser } from "@/lib/session"; 4 | import { getGroupImageUrlUseCase } from "@/use-cases/files"; 5 | import { NextResponse } from "next/server"; 6 | 7 | export const GET = async ( 8 | req: Request, 9 | { params }: { params: Promise<{ groupId: string; imageId: string }> } 10 | ) => { 11 | try { 12 | let { groupId, imageId } = await params; 13 | const groupIdInt = parseInt(groupId); 14 | 15 | const user = await getCurrentUser(); 16 | 17 | const url = 18 | imageId === "default" 19 | ? `${env.HOST_NAME}/group.jpeg` 20 | : await getGroupImageUrlUseCase(user, { 21 | imageId, 22 | groupId: groupIdInt, 23 | }); 24 | 25 | return streamImageFromUrl(url); 26 | } catch (error) { 27 | const err = error as Error; 28 | return NextResponse.json({ error: err.message }, { status: 400 }); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/app/(main)/api/login/github/route.ts: -------------------------------------------------------------------------------- 1 | import { github } from "@/auth"; 2 | import { generateState } from "arctic"; 3 | import { cookies } from "next/headers"; 4 | 5 | export async function GET(): Promise { 6 | const state = generateState(); 7 | const allCookies = await cookies(); 8 | const url = await github.createAuthorizationURL(state, ["user:email"]); 9 | 10 | allCookies.set("github_oauth_state", state, { 11 | path: "/", 12 | secure: process.env.NODE_ENV === "production", 13 | httpOnly: true, 14 | maxAge: 60 * 10, 15 | sameSite: "lax", 16 | }); 17 | 18 | return Response.redirect(url); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(main)/api/login/google/route.ts: -------------------------------------------------------------------------------- 1 | import { googleAuth } from "@/auth"; 2 | import { cookies } from "next/headers"; 3 | import { generateCodeVerifier, generateState } from "arctic"; 4 | 5 | export async function GET(): Promise { 6 | const state = generateState(); 7 | const codeVerifier = generateCodeVerifier(); 8 | const url = await googleAuth.createAuthorizationURL(state, codeVerifier, [ 9 | "profile", 10 | "email", 11 | ]); 12 | 13 | const allCookies = await cookies(); 14 | 15 | allCookies.set("google_oauth_state", state, { 16 | secure: true, 17 | path: "/", 18 | httpOnly: true, 19 | maxAge: 60 * 10, 20 | }); 21 | 22 | allCookies.set("google_code_verifier", codeVerifier, { 23 | secure: true, 24 | path: "/", 25 | httpOnly: true, 26 | maxAge: 60 * 10, 27 | }); 28 | 29 | return Response.redirect(url); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/(main)/api/login/magic/route.ts: -------------------------------------------------------------------------------- 1 | import { afterLoginUrl } from "@/app-config"; 2 | import { rateLimitByIp } from "@/lib/limiter"; 3 | import { setSession } from "@/lib/session"; 4 | import { loginWithMagicLinkUseCase } from "@/use-cases/magic-link"; 5 | 6 | export const dynamic = "force-dynamic"; 7 | 8 | export async function GET(request: Request): Promise { 9 | try { 10 | await rateLimitByIp({ key: "magic-token", limit: 5, window: 60000 }); 11 | const url = new URL(request.url); 12 | const token = url.searchParams.get("token"); 13 | 14 | if (!token) { 15 | return new Response(null, { 16 | status: 302, 17 | headers: { 18 | Location: "/sign-in", 19 | }, 20 | }); 21 | } 22 | 23 | const user = await loginWithMagicLinkUseCase(token); 24 | 25 | await setSession(user.id); 26 | 27 | return new Response(null, { 28 | status: 302, 29 | headers: { 30 | Location: afterLoginUrl, 31 | }, 32 | }); 33 | } catch (err) { 34 | return new Response(null, { 35 | status: 302, 36 | headers: { 37 | Location: "/sign-in/magic/error", 38 | }, 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/(main)/api/login/verify-email/route.ts: -------------------------------------------------------------------------------- 1 | import { rateLimitByIp } from "@/lib/limiter"; 2 | import { verifyEmailUseCase } from "@/use-cases/users"; 3 | 4 | export const dynamic = "force-dynamic"; 5 | 6 | export async function GET(request: Request): Promise { 7 | try { 8 | await rateLimitByIp({ key: "verify-email", limit: 5, window: 60000 }); 9 | 10 | const url = new URL(request.url); 11 | const token = url.searchParams.get("token"); 12 | 13 | if (!token) { 14 | return new Response(null, { 15 | status: 302, 16 | headers: { 17 | Location: "/sign-in", 18 | }, 19 | }); 20 | } 21 | 22 | await verifyEmailUseCase(token); 23 | 24 | return new Response(null, { 25 | status: 302, 26 | headers: { 27 | Location: "/verify-success", 28 | }, 29 | }); 30 | } catch (err) { 31 | console.error(err); 32 | return new Response(null, { 33 | status: 302, 34 | headers: { 35 | Location: "/sign-in", 36 | }, 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/(main)/api/search/route.ts: -------------------------------------------------------------------------------- 1 | import { getPages } from "@/app/(main)/source"; 2 | import { createSearchAPI } from "fumadocs-core/search/server"; 3 | 4 | export const { GET } = createSearchAPI("advanced", { 5 | indexes: getPages().map((page) => ({ 6 | title: page.data.title, 7 | structuredData: page.data.exports.structuredData, 8 | id: page.url, 9 | url: page.url, 10 | })), 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/(main)/api/streams.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | export async function streamImageFromUrl(url: string) { 4 | const fetchResponse = await fetch(url); 5 | 6 | if (!fetchResponse.ok) { 7 | return NextResponse.json({ error: "File not found" }, { status: 404 }); 8 | } 9 | 10 | const file = fetchResponse.body; 11 | 12 | if (!file) { 13 | return NextResponse.json({ error: "File not found" }, { status: 404 }); 14 | } 15 | 16 | const contentType = fetchResponse.headers.get("content-type") || "image/*"; 17 | const contentLength = 18 | Number(fetchResponse.headers.get("content-length")) || 0; 19 | 20 | const stream = new ReadableStream({ 21 | start(controller) { 22 | const reader = file.getReader(); 23 | const pump = () => { 24 | reader.read().then(({ done, value }) => { 25 | if (done) { 26 | controller.close(); 27 | return; 28 | } 29 | controller.enqueue(value); 30 | pump(); 31 | }); 32 | }; 33 | pump(); 34 | }, 35 | }); 36 | 37 | return new Response(stream, { 38 | headers: { 39 | "Content-Type": contentType, 40 | "Content-Length": String(contentLength), 41 | "Cache-Control": "public, max-age=31536000, immutable", 42 | }, 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/(main)/api/users/[userId]/images/[imageId]/route.ts: -------------------------------------------------------------------------------- 1 | import { streamImageFromUrl } from "@/app/(main)/api/streams"; 2 | import { env } from "@/env"; 3 | import { getProfileImageUrlUseCase } from "@/use-cases/users"; 4 | import { NextResponse } from "next/server"; 5 | 6 | export const GET = async ( 7 | req: Request, 8 | { params }: { params: Promise<{ userId: string; imageId: string }> } 9 | ) => { 10 | const { userId, imageId } = await params; 11 | try { 12 | if (!imageId) { 13 | return NextResponse.json( 14 | { error: "Image ID is required" }, 15 | { status: 400 } 16 | ); 17 | } 18 | 19 | const url = 20 | imageId === "default" 21 | ? `${env.HOST_NAME}/group.jpeg` 22 | : await getProfileImageUrlUseCase({ 23 | userId: parseInt(userId), 24 | imageId: imageId, 25 | }); 26 | 27 | return streamImageFromUrl(url); 28 | } catch (error) { 29 | const err = error as Error; 30 | return NextResponse.json({ error: err.message }, { status: 400 }); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/app/(main)/api/webhooks/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env"; 2 | import { stripe } from "@/lib/stripe"; 3 | import { headers } from "next/headers"; 4 | import type Stripe from "stripe"; 5 | import { 6 | createSubscriptionUseCase, 7 | updateSubscriptionUseCase, 8 | } from "@/use-cases/subscriptions"; 9 | 10 | export async function POST(req: Request) { 11 | const body = await req.text(); 12 | const signature = headers().get("Stripe-Signature") as string; 13 | 14 | let event: Stripe.Event; 15 | 16 | try { 17 | event = stripe.webhooks.constructEvent( 18 | body, 19 | signature, 20 | env.STRIPE_WEBHOOK_SECRET 21 | ); 22 | } catch (error) { 23 | return new Response( 24 | `Webhook Error: ${ 25 | error instanceof Error ? error.message : "Unknown error" 26 | }`, 27 | { status: 400 } 28 | ); 29 | } 30 | 31 | if (event.type === "checkout.session.completed") { 32 | const session = event.data.object as Stripe.Checkout.Session; 33 | const subscription = await stripe.subscriptions.retrieve( 34 | session.subscription as string 35 | ); 36 | 37 | await createSubscriptionUseCase({ 38 | userId: parseInt(session.metadata!.userId), 39 | stripeSubscriptionId: subscription.id, 40 | stripeCustomerId: subscription.customer as string, 41 | stripePriceId: subscription.items.data[0]?.price.id, 42 | stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000), 43 | }); 44 | } else if ( 45 | ["customer.subscription.created", "customer.subscription.updated"].includes( 46 | event.type 47 | ) 48 | ) { 49 | const subscription = event.data.object as Stripe.Subscription; 50 | // if this fails due to race conditions where checkout.session.completed was not fired first, stripe will retry 51 | await updateSubscriptionUseCase({ 52 | stripePriceId: subscription.items.data[0]?.price.id, 53 | stripeSubscriptionId: subscription.id, 54 | stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000), 55 | }); 56 | } 57 | 58 | return new Response(null, { status: 200 }); 59 | } 60 | -------------------------------------------------------------------------------- /src/app/(main)/browse/pagination.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Pagination, 3 | PaginationContent, 4 | PaginationEllipsis, 5 | PaginationItem, 6 | PaginationLink, 7 | PaginationNext, 8 | PaginationPrevious, 9 | } from "@/components/ui/pagination"; 10 | 11 | export function GroupPagination({ 12 | page, 13 | totalPages, 14 | search, 15 | }: { 16 | page: number; 17 | totalPages: number; 18 | search: string; 19 | }) { 20 | return ( 21 | 22 | 23 | {page > 1 && ( 24 | <> 25 | 26 | 29 | 30 | 31 | {page > 2 && ( 32 | 33 | 34 | 35 | )} 36 | 37 | 38 | 42 | {page - 1} 43 | 44 | 45 | 46 | )} 47 | 48 | 49 | 50 | {page} 51 | 52 | 53 | 54 | {page < totalPages && ( 55 | <> 56 | 57 | 60 | {page + 1} 61 | 62 | 63 | 64 | {page < totalPages - 1 && ( 65 | 66 | 67 | 68 | )} 69 | 70 | 71 | 74 | 75 | 76 | )} 77 | 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { rateLimitByKey } from "@/lib/limiter"; 4 | import { authenticatedAction } from "@/lib/safe-action"; 5 | import { createGroupUseCase } from "@/use-cases/groups"; 6 | import { schema } from "./validation"; 7 | import { revalidatePath } from "next/cache"; 8 | 9 | export const createGroupAction = authenticatedAction 10 | .createServerAction() 11 | .input(schema) 12 | .handler(async ({ input: { name, description }, ctx: { user } }) => { 13 | await rateLimitByKey({ 14 | key: `${user.id}-create-group`, 15 | }); 16 | await createGroupUseCase(user, { 17 | name, 18 | description, 19 | }); 20 | revalidatePath("/dashboard"); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/create-group-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { PlusCircle } from "lucide-react"; 5 | import { btnIconStyles, btnStyles } from "@/styles/icons"; 6 | import { InteractiveOverlay } from "@/components/interactive-overlay"; 7 | import { useState } from "react"; 8 | import { CreateGroupForm } from "./create-group-form"; 9 | 10 | export function CreateGroupButton() { 11 | const [isOpen, setIsOpen] = useState(false); 12 | 13 | return ( 14 | <> 15 | } 21 | /> 22 | 23 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/group-card.tsx: -------------------------------------------------------------------------------- 1 | import { getGroupImageUrl } from "@/app/(main)/dashboard/groups/[groupId]/settings/util"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardFooter, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import { Group } from "@/db/schema"; 12 | import { cn } from "@/lib/utils"; 13 | import { cardStyles } from "@/styles/common"; 14 | import { UsersIcon } from "lucide-react"; 15 | import Image from "next/image"; 16 | import Link from "next/link"; 17 | 18 | export function GroupCard({ 19 | group, 20 | buttonText, 21 | memberCount, 22 | }: { 23 | group: Pick; 24 | buttonText: string; 25 | memberCount: string; 26 | }) { 27 | return ( 28 | 29 | 30 | image of the group 37 | {group.name} 38 | 39 | {group.description} 40 | 41 | 42 | 43 |
44 | {memberCount} members 45 |
46 |
47 | 48 | 51 | 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/[...not-found]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation"; 2 | 3 | const catchAll = () => { 4 | return notFound(); 5 | }; 6 | 7 | export default catchAll; 8 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { authenticatedAction } from "@/lib/safe-action"; 4 | import { getPublicGroupInfoByIdUseCase } from "@/use-cases/groups"; 5 | import { sendInviteUseCase } from "@/use-cases/invites"; 6 | import { joinGroupUseCase, leaveGroupUseCase } from "@/use-cases/membership"; 7 | import { revalidatePath } from "next/cache"; 8 | import { redirect } from "next/navigation"; 9 | import { z } from "zod"; 10 | 11 | export const joinGroupAction = authenticatedAction 12 | .createServerAction() 13 | .input(z.number()) 14 | .handler(async ({ input: groupId, ctx }) => { 15 | await joinGroupUseCase(ctx.user, groupId); 16 | revalidatePath(`/dashboard/groups/${groupId}`, "layout"); 17 | }); 18 | 19 | export const leaveGroupAction = authenticatedAction 20 | .createServerAction() 21 | .input(z.number()) 22 | .handler(async ({ input: groupId, ctx }) => { 23 | const group = await getPublicGroupInfoByIdUseCase(groupId); 24 | await leaveGroupUseCase(ctx.user, groupId); 25 | if (group?.isPublic) { 26 | revalidatePath(`/dashboard/groups/${groupId}`, "layout"); 27 | } else { 28 | redirect("/dashboard"); 29 | } 30 | }); 31 | 32 | export const sendInviteAction = authenticatedAction 33 | .createServerAction() 34 | .input( 35 | z.object({ 36 | email: z.string().email(), 37 | groupId: z.number(), 38 | }) 39 | ) 40 | .handler(async ({ input: { email, groupId }, ctx }) => { 41 | await sendInviteUseCase(ctx.user, { email, groupId }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/danger/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { authenticatedAction } from "@/lib/safe-action"; 4 | import { deleteGroupUseCase } from "@/use-cases/groups"; 5 | import { redirect } from "next/navigation"; 6 | import { z } from "zod"; 7 | 8 | export const deleteGroupAction = authenticatedAction 9 | .createServerAction() 10 | .input( 11 | z.object({ 12 | groupId: z.number(), 13 | }) 14 | ) 15 | .handler(async ({ input, ctx }) => { 16 | const groupId = input.groupId; 17 | await deleteGroupUseCase(ctx.user, { 18 | groupId, 19 | }); 20 | redirect("/dashboard"); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/danger/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export default function ErrorPage({ 4 | error, 5 | }: { 6 | error: Error & { digest?: string }; 7 | }) { 8 | return
{error.message}!!
; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/danger/page.tsx: -------------------------------------------------------------------------------- 1 | import { assertAuthenticated } from "@/lib/session"; 2 | import { pageTitleStyles } from "@/styles/common"; 3 | import { getGroupByIdUseCase } from "@/use-cases/groups"; 4 | import { ConfigurationPanel } from "@/components/configuration-panel"; 5 | import { DeleteGroupButton } from "./delete-group-button"; 6 | 7 | export default async function DangerTab({ 8 | params, 9 | }: { 10 | params: Promise<{ groupId: string }>; 11 | }) { 12 | const { groupId } = await params; 13 | const groupIdInt = parseInt(groupId); 14 | const user = await assertAuthenticated(); 15 | const group = await getGroupByIdUseCase(user, groupIdInt); 16 | 17 | if (!group) { 18 | return
Group not found
; 19 | } 20 | 21 | return ( 22 |
23 |

Danger

24 | 25 |
26 | 27 |
28 |

29 | Delete this group and all it's data. 30 |

31 | 32 |
33 |
34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/events/create-event-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { useParams } from "next/navigation"; 5 | import { CreateEventForm } from "./create-event-form"; 6 | import { Calendar } from "lucide-react"; 7 | import { btnIconStyles, btnStyles } from "@/styles/icons"; 8 | import { InteractiveOverlay } from "@/components/interactive-overlay"; 9 | import { useState } from "react"; 10 | 11 | export function CreateEventButton() { 12 | const { groupId } = useParams<{ groupId: string }>(); 13 | const [isOpen, setIsOpen] = useState(false); 14 | 15 | return ( 16 | <> 17 | } 23 | /> 24 | 25 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/events/event-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Event } from "@/db/schema"; 4 | import { getEventImageUrl } from "../settings/util"; 5 | import Image from "next/image"; 6 | import { format } from "date-fns"; 7 | import { EventCardActions } from "./event-card-actions"; 8 | import { cn } from "@/lib/utils"; 9 | import { cardStyles } from "@/styles/common"; 10 | 11 | export function EventCard({ 12 | event, 13 | isAdmin, 14 | }: { 15 | event: Event; 16 | isAdmin: boolean; 17 | }) { 18 | return ( 19 |
20 |
21 | image of the event 28 |
29 |

{event.name}

30 |

31 | {format(event.startsOn, "PPp")} 32 |

33 |

{event.description}

34 |
35 | 36 | {isAdmin && ( 37 |
38 | 39 |
40 | )} 41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/info/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { authenticatedAction } from "@/lib/safe-action"; 4 | import { updateGroupInfoUseCase } from "@/use-cases/groups"; 5 | import { revalidatePath } from "next/cache"; 6 | import { z } from "zod"; 7 | import sanitizeHtml from "sanitize-html"; 8 | 9 | export const updateGroupInfoAction = authenticatedAction 10 | .createServerAction() 11 | .input( 12 | z.object({ 13 | groupId: z.number(), 14 | info: z.string(), 15 | }) 16 | ) 17 | .handler(async ({ input, ctx }) => { 18 | await updateGroupInfoUseCase(ctx.user, { 19 | groupId: input.groupId, 20 | info: sanitizeHtml(input.info), 21 | }); 22 | 23 | revalidatePath(`/dashboard/groups/${input.groupId}/info`); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/join-group-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { joinGroupAction } from "@/app/(main)/dashboard/groups/[groupId]/actions"; 4 | import { LoaderButton } from "@/components/loader-button"; 5 | import { useToast } from "@/components/ui/use-toast"; 6 | import { btnIconStyles } from "@/styles/icons"; 7 | import { Handshake } from "lucide-react"; 8 | import { useParams } from "next/navigation"; 9 | import { useServerAction } from "zsa-react"; 10 | 11 | export function JoinGroupButton() { 12 | const { groupId } = useParams<{ groupId: string }>(); 13 | const { toast } = useToast(); 14 | const { execute, status } = useServerAction(joinGroupAction, { 15 | onSuccess() { 16 | toast({ 17 | title: "Success", 18 | description: "You joined this group.", 19 | }); 20 | }, 21 | }); 22 | 23 | return ( 24 | { 28 | execute(parseInt(groupId)); 29 | }} 30 | > 31 | Join Group 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { GroupHeader } from "@/app/(main)/dashboard/groups/[groupId]/group-header"; 2 | import { TabsSection } from "@/app/(main)/dashboard/groups/[groupId]/tabs-section"; 3 | import { NotFoundError, PrivateGroupAccessError } from "@/app/(main)/util"; 4 | import { getCurrentUser } from "@/lib/session"; 5 | import { pageWrapperStyles } from "@/styles/common"; 6 | import { getPublicGroupInfoByIdUseCase } from "@/use-cases/groups"; 7 | import { 8 | isGroupOwnerUseCase, 9 | isGroupVisibleToUserUseCase, 10 | } from "@/use-cases/membership"; 11 | import { ReactNode } from "react"; 12 | 13 | export default async function GroupLayout({ 14 | children, 15 | params, 16 | }: { 17 | children: ReactNode; 18 | params: Promise<{ groupId: string }>; 19 | }) { 20 | const { groupId } = await params; 21 | const groupIdInt = parseInt(groupId); 22 | const user = await getCurrentUser(); 23 | 24 | const group = await getPublicGroupInfoByIdUseCase(groupIdInt); 25 | 26 | if (!group) { 27 | throw new NotFoundError("Group not found."); 28 | } 29 | 30 | const isGroupVisibleToUser = await isGroupVisibleToUserUseCase( 31 | user, 32 | group.id 33 | ); 34 | 35 | const isGroupOwner = user ? await isGroupOwnerUseCase(user, group.id) : false; 36 | 37 | if (!isGroupVisibleToUser) { 38 | throw new PrivateGroupAccessError(); 39 | } 40 | 41 | return ( 42 |
43 | 44 | 45 | 46 | 47 |
{children}
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/members/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { authenticatedAction } from "@/lib/safe-action"; 4 | import { 5 | kickMemberUseCase, 6 | switchMemberRoleUseCase, 7 | } from "@/use-cases/membership"; 8 | import { revalidatePath } from "next/cache"; 9 | import { z } from "zod"; 10 | 11 | export const kickMemberAction = authenticatedAction 12 | .createServerAction() 13 | .input( 14 | z.object({ 15 | userId: z.number(), 16 | groupId: z.number(), 17 | }) 18 | ) 19 | .handler(async ({ input, ctx }) => { 20 | await kickMemberUseCase(ctx.user, { 21 | userId: input.userId, 22 | groupId: input.groupId, 23 | }); 24 | revalidatePath(`/dashboard/groups/${input.groupId}/members`); 25 | }); 26 | 27 | export const switchMemberRoleAction = authenticatedAction 28 | .createServerAction() 29 | .input( 30 | z.object({ 31 | userId: z.number(), 32 | groupId: z.number(), 33 | role: z.literal("admin").or(z.literal("member")), 34 | }) 35 | ) 36 | .handler(async ({ input, ctx }) => { 37 | await switchMemberRoleUseCase(ctx.user, { 38 | userId: input.userId, 39 | groupId: input.groupId, 40 | role: input.role, 41 | }); 42 | revalidatePath(`/dashboard/groups/${input.groupId}/members`); 43 | }); 44 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/not-found.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | import { useParams } from "next/navigation"; 7 | 8 | export default function NotFound() { 9 | const { groupId } = useParams(); 10 | 11 | return ( 12 |
13 | no image placeholder image 19 |

Uh-oh, this route wasn't found

20 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default async function GroupPage(props: { 4 | params: Promise<{ groupId: string }>; 5 | }) { 6 | const { groupId } = await props.params; 7 | redirect(`/dashboard/groups/${groupId}/info`); 8 | } 9 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/posts/[postId]/loaders.ts: -------------------------------------------------------------------------------- 1 | import { getUserProfileUseCase } from "@/use-cases/users"; 2 | import { cache } from "react"; 3 | 4 | export const getUserProfileLoader = cache(getUserProfileUseCase); 5 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/posts/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { authenticatedAction } from "@/lib/safe-action"; 4 | import { updateGroupInfoUseCase } from "@/use-cases/groups"; 5 | import { createPostUseCase, deletePostUseCase } from "@/use-cases/posts"; 6 | import { revalidatePath } from "next/cache"; 7 | import { redirect } from "next/navigation"; 8 | import { z } from "zod"; 9 | 10 | export const updateGroupInfoAction = authenticatedAction 11 | .createServerAction() 12 | .input( 13 | z.object({ 14 | groupId: z.number(), 15 | info: z.string(), 16 | }) 17 | ) 18 | .handler(async ({ input, ctx }) => { 19 | await updateGroupInfoUseCase(ctx.user, { 20 | groupId: input.groupId, 21 | info: input.info, 22 | }); 23 | 24 | revalidatePath(`/dashboard/groups/${input.groupId}/info`); 25 | }); 26 | 27 | export const createPostAction = authenticatedAction 28 | .createServerAction() 29 | .input( 30 | z.object({ 31 | groupId: z.number().min(1), 32 | title: z.string().min(1), 33 | message: z.string().min(1), 34 | }) 35 | ) 36 | .handler(async ({ input: { groupId, title, message }, ctx: { user } }) => { 37 | await createPostUseCase(user, { 38 | groupId, 39 | title, 40 | message, 41 | }); 42 | revalidatePath(`/dashboard/groups/${groupId}/posts`); 43 | }); 44 | 45 | export const deletePostAction = authenticatedAction 46 | .createServerAction() 47 | .input( 48 | z.object({ 49 | postId: z.number(), 50 | }) 51 | ) 52 | .handler(async ({ input: { postId }, ctx: { user } }) => { 53 | const post = await deletePostUseCase(user, { 54 | postId, 55 | }); 56 | redirect(`/dashboard/groups/${post.groupId}/posts`); 57 | }); 58 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/posts/create-post-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { useParams } from "next/navigation"; 5 | import { CreateEventForm } from "./create-post-form"; 6 | import { Calendar } from "lucide-react"; 7 | import { btnIconStyles, btnStyles } from "@/styles/icons"; 8 | import { InteractiveOverlay } from "@/components/interactive-overlay"; 9 | import { useState } from "react"; 10 | 11 | export function CreatePostButton() { 12 | const { groupId } = useParams<{ groupId: string }>(); 13 | const [isOpen, setIsOpen] = useState(false); 14 | 15 | return ( 16 | <> 17 | } 23 | /> 24 | 25 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/posts/delete-post-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoaderButton } from "@/components/loader-button"; 4 | import { 5 | AlertDialog, 6 | AlertDialogCancel, 7 | AlertDialogContent, 8 | AlertDialogDescription, 9 | AlertDialogFooter, 10 | AlertDialogHeader, 11 | AlertDialogTitle, 12 | AlertDialogTrigger, 13 | } from "@/components/ui/alert-dialog"; 14 | import { Button } from "@/components/ui/button"; 15 | import { useToast } from "@/components/ui/use-toast"; 16 | import { btnIconStyles, btnStyles } from "@/styles/icons"; 17 | import { DoorOpen, Trash } from "lucide-react"; 18 | import { useState } from "react"; 19 | import { useServerAction } from "zsa-react"; 20 | import { cn } from "@/lib/utils"; 21 | import { deletePostAction } from "./actions"; 22 | 23 | export function DeletePostButton({ postId }: { postId: number }) { 24 | const { toast } = useToast(); 25 | const [isOpen, setIsOpen] = useState(false); 26 | const { execute, isPending } = useServerAction(deletePostAction, { 27 | onSuccess() { 28 | toast({ 29 | title: "Success", 30 | description: "Post deleted successfully", 31 | }); 32 | }, 33 | }); 34 | 35 | return ( 36 | 37 | 38 | 41 | 42 | 43 | 44 | Delete Post 45 | 46 | Are you sure you want to delete this post? 47 | 48 | 49 | 50 | 51 | Cancel 52 | { 55 | execute({ postId }); 56 | }} 57 | > 58 | Delete Post 59 | 60 | 61 | 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/settings/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export default function ErrorPage({ 4 | error, 5 | }: { 6 | error: Error & { digest?: string }; 7 | }) { 8 | return
{error.message}
; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/settings/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | export const socialUrlSchema = { 3 | youtubeLink: z 4 | .string() 5 | .url() 6 | .regex(/https\:\/\/youtube\.com/, { 7 | message: "Must be a Youtube link", 8 | }) 9 | .optional() 10 | .or(z.literal("")), 11 | discordLink: z 12 | .string() 13 | .url() 14 | .regex(/https\:\/\/(discord\.gg|discordapp\.com)/, { 15 | message: "Must be a Discord link", 16 | }) 17 | .optional() 18 | .or(z.literal("")), 19 | xLink: z 20 | .string() 21 | .url() 22 | .regex(/https\:\/\/(x|twitter)\.com/, { 23 | message: "Must be an X link", 24 | }) 25 | .optional() 26 | .or(z.literal("")), 27 | githubLink: z 28 | .string() 29 | .url() 30 | .regex(/https\:\/\/github\.com/, { 31 | message: "Must be a Github link", 32 | }) 33 | .optional() 34 | .or(z.literal("")), 35 | }; 36 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/settings/util.ts: -------------------------------------------------------------------------------- 1 | import { Event, Group } from "@/db/schema"; 2 | 3 | export function getGroupImageUrl(group: Pick) { 4 | return `/api/groups/${group.id}/images/${group.bannerId ?? "default"}`; 5 | } 6 | 7 | export function getEventImageUrl(event: Event) { 8 | return `/api/groups/${event.groupId}/images/${event.imageId ?? "default"}`; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/settings/visibility-switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toggleGroupVisibilityAction } from "@/app/(main)/dashboard/groups/[groupId]/settings/actions"; 4 | import { Label } from "@/components/ui/label"; 5 | import { Switch } from "@/components/ui/switch"; 6 | import { useToast } from "@/components/ui/use-toast"; 7 | import { Group } from "@/db/schema"; 8 | import { useServerAction } from "zsa-react"; 9 | 10 | export function GroupVisibilitySwitch({ group }: { group: Group }) { 11 | const { toast } = useToast(); 12 | 13 | const { execute } = useServerAction(toggleGroupVisibilityAction, { 14 | onSuccess() { 15 | toast({ 16 | title: "Update successful", 17 | description: "Group visibility updated.", 18 | }); 19 | }, 20 | onError({ err }) { 21 | toast({ 22 | title: "Something went wrong", 23 | description: err.message, 24 | variant: "destructive", 25 | }); 26 | }, 27 | }); 28 | 29 | return ( 30 |
31 | { 34 | execute(group.id); 35 | }} 36 | id="visibility" 37 | /> 38 | 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/tabs-section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; 4 | import { cn } from "@/lib/utils"; 5 | import { tabStyles } from "@/styles/common"; 6 | import Link from "next/link"; 7 | import { usePathname } from "next/navigation"; 8 | 9 | export function TabsSection({ 10 | groupId, 11 | showSettings, 12 | }: { 13 | groupId: string; 14 | showSettings: boolean; 15 | }) { 16 | const path = usePathname(); 17 | const tabInUrl = path.includes("/posts") ? "posts" : path.split("/").pop(); 18 | 19 | return ( 20 |
21 |
22 | 23 | 24 | 25 | Info 26 | 27 | 28 | 29 | Posts 30 | 31 | 32 | 33 | Events 34 | 35 | 36 | 37 | Members 38 | 39 | 40 | {showSettings && ( 41 | <> 42 | 43 | 44 | Settings 45 | 46 | 47 | 48 | 49 | 50 | Danger 51 | 52 | 53 | 54 | )} 55 | 56 | 57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/groups/[groupId]/utils.ts: -------------------------------------------------------------------------------- 1 | import { useSafeParams } from "@/lib/params"; 2 | import { z } from "zod"; 3 | 4 | export function useGroupIdParam() { 5 | const { groupId } = useSafeParams( 6 | z.object({ groupId: z.string().pipe(z.coerce.number()) }) 7 | ); 8 | return groupId; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export default async function DashboardLayout({ 4 | children, 5 | }: Readonly<{ 6 | children: ReactNode; 7 | }>) { 8 | return
{children}
; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/danger/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { authenticatedAction } from "@/lib/safe-action"; 4 | import { deleteUserUseCase } from "@/use-cases/users"; 5 | import { redirect } from "next/navigation"; 6 | import { z } from "zod"; 7 | 8 | export const deleteAccountAction = authenticatedAction 9 | .createServerAction() 10 | .input(z.void()) 11 | .handler(async ({ input, ctx: { user } }) => { 12 | await deleteUserUseCase(user, user.id); 13 | redirect("/"); 14 | }); 15 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/danger/page.tsx: -------------------------------------------------------------------------------- 1 | import { ConfigurationPanel } from "@/components/configuration-panel"; 2 | import { DeleteAccountButton } from "./delete-account-button"; 3 | 4 | export default async function DangerPage() { 5 | return ( 6 | 7 |
8 |
You can delete your account below
9 | 10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from "@/lib/session"; 2 | import { SettingsTab } from "@/app/(main)/dashboard/settings/tabs-section"; 3 | import { Button } from "@/components/ui/button"; 4 | import Link from "next/link"; 5 | import { SquareUser } from "lucide-react"; 6 | import { btnIconStyles, btnStyles } from "@/styles/icons"; 7 | import { getUserPlanUseCase } from "@/use-cases/subscriptions"; 8 | import { Suspense } from "react"; 9 | import { Skeleton } from "@/components/ui/skeleton"; 10 | import { headerStyles } from "@/styles/common"; 11 | import { cn } from "@/lib/utils"; 12 | 13 | export default async function SettingsPage({ 14 | children, 15 | }: { 16 | children: React.ReactNode; 17 | }) { 18 | return ( 19 | <> 20 |
21 |
22 |
23 |

Account Settings

24 | 25 | } 27 | > 28 | 29 | 30 |
31 |
32 |
33 | }> 34 | 35 | 36 | 37 |
{children}
38 | 39 | ); 40 | } 41 | 42 | async function SettingsTabWrapper() { 43 | const user = await getCurrentUser(); 44 | const plan = await getUserPlanUseCase(user!.id); 45 | return ; 46 | } 47 | 48 | async function SwitchProfileButton() { 49 | const user = await getCurrentUser(); 50 | return ( 51 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default async function SettingPage() { 4 | redirect(`/dashboard/settings/profile`); 5 | } 6 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/profile/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { authenticatedAction } from "@/lib/safe-action"; 4 | import { 5 | updateProfileBioUseCase, 6 | updateProfileNameUseCase, 7 | } from "@/use-cases/users"; 8 | import { z } from "zod"; 9 | import { updateProfileImageUseCase } from "@/use-cases/users"; 10 | import { revalidatePath } from "next/cache"; 11 | import { rateLimitByKey } from "@/lib/limiter"; 12 | import sanitizeHtml from "sanitize-html"; 13 | 14 | export const updateProfileImageAction = authenticatedAction 15 | .createServerAction() 16 | .input( 17 | z.object({ 18 | fileWrapper: z.instanceof(FormData), 19 | }) 20 | ) 21 | .handler(async ({ input, ctx }) => { 22 | await rateLimitByKey({ 23 | key: `update-profile-image-${ctx.user.id}`, 24 | limit: 3, 25 | window: 60000, 26 | }); 27 | const file = input.fileWrapper.get("file") as File; 28 | await updateProfileImageUseCase(file, ctx.user.id); 29 | revalidatePath(`/dashboard/settings/profile`); 30 | }); 31 | 32 | export const updateProfileNameAction = authenticatedAction 33 | .createServerAction() 34 | .input( 35 | z.object({ 36 | profileName: z.string(), 37 | }) 38 | ) 39 | .handler(async ({ input, ctx }) => { 40 | await updateProfileNameUseCase(ctx.user.id, input.profileName); 41 | revalidatePath(`/dashboard/settings/profile`); 42 | }); 43 | 44 | export const updateProfileBioAction = authenticatedAction 45 | .createServerAction() 46 | .input( 47 | z.object({ 48 | bio: z.string(), 49 | }) 50 | ) 51 | .handler(async ({ input, ctx }) => { 52 | await updateProfileBioUseCase(ctx.user.id, sanitizeHtml(input.bio)); 53 | revalidatePath(`/dashboard/settings/profile`); 54 | }); 55 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/profile/edit-bio-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { EditorProvider } from "@tiptap/react"; 4 | import { 5 | MenuBar, 6 | extensions, 7 | } from "../../groups/[groupId]/info/edit-group-info-form"; 8 | import { LoaderButton } from "@/components/loader-button"; 9 | import { useServerAction } from "zsa-react"; 10 | import { updateProfileBioAction } from "./actions"; 11 | import { useRef } from "react"; 12 | import { useToast } from "@/components/ui/use-toast"; 13 | 14 | export function EditBioForm({ bio }: { bio: string }) { 15 | const { execute, isPending } = useServerAction(updateProfileBioAction); 16 | const htmlRef = useRef(bio); 17 | const { toast } = useToast(); 18 | 19 | return ( 20 |
21 | { 23 | htmlRef.current = editor.getHTML(); 24 | }} 25 | slotBefore={} 26 | extensions={extensions} 27 | content={bio} 28 | editable={true} 29 | > 30 | 31 |
32 | { 34 | execute({ bio: htmlRef.current }).then(([, err]) => { 35 | if (err) { 36 | toast({ 37 | title: "Uh-oh!", 38 | variant: "destructive", 39 | description: "Your profile bio failed to update.", 40 | }); 41 | } else { 42 | toast({ 43 | title: "Success!", 44 | description: "Your profile bio has been updated.", 45 | }); 46 | } 47 | }); 48 | }} 49 | isLoading={isPending} 50 | className="self-end" 51 | > 52 | Save Changes 53 | 54 |
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { ProfileImage } from "@/app/(main)/dashboard/settings/profile/profile-image"; 2 | import { ProfileName } from "@/app/(main)/dashboard/settings/profile/profile-name"; 3 | import { EditBioForm } from "./edit-bio-form"; 4 | import { assertAuthenticated } from "@/lib/session"; 5 | import { Suspense, cache } from "react"; 6 | import { getUserProfileUseCase } from "@/use-cases/users"; 7 | import { Skeleton } from "@/components/ui/skeleton"; 8 | import { ConfigurationPanel } from "@/components/configuration-panel"; 9 | import { ModeToggle } from "@/components/mode-toggle"; 10 | 11 | export const getUserProfileLoader = cache(getUserProfileUseCase); 12 | 13 | export default async function SettingsPage() { 14 | return ( 15 |
16 |
17 | 18 | 19 |
20 | 21 | 22 | }> 23 | 24 | 25 | 26 | 27 | 28 |
29 | Toggle dark mode 30 | 31 |
32 |
33 |
34 | ); 35 | } 36 | 37 | export async function BioFormWrapper() { 38 | const user = await assertAuthenticated(); 39 | const profile = await getUserProfileLoader(user.id); 40 | return ; 41 | } 42 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/profile/profile-image.tsx: -------------------------------------------------------------------------------- 1 | import { Profile } from "@/db/schema"; 2 | import { ProfileImageForm } from "./profile-image-form"; 3 | import { getCurrentUser } from "@/lib/session"; 4 | import { getProfileImageUrl } from "@/use-cases/users"; 5 | import Image from "next/image"; 6 | import { ConfigurationPanel } from "@/components/configuration-panel"; 7 | import { Skeleton } from "@/components/ui/skeleton"; 8 | import { Suspense } from "react"; 9 | import { getUserProfileLoader } from "./page"; 10 | 11 | export function getProfileImageFullUrl(profile: Profile) { 12 | return profile.imageId 13 | ? getProfileImageUrl(profile.userId, profile.imageId) 14 | : profile.image 15 | ? profile.image 16 | : "/profile.png"; 17 | } 18 | 19 | export async function ProfileImage() { 20 | return ( 21 | 22 | }> 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | async function ProfileImageContent() { 30 | const user = await getCurrentUser(); 31 | 32 | if (!user) { 33 | return null; 34 | } 35 | 36 | const profile = await getUserProfileLoader(user.id); 37 | 38 | return ( 39 |
40 | Profile image 47 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/profile/profile-name.tsx: -------------------------------------------------------------------------------- 1 | import { ConfigurationPanel } from "@/components/configuration-panel"; 2 | import { ProfileNameForm } from "./profile-name-form"; 3 | import { getCurrentUser } from "@/lib/session"; 4 | import { Suspense } from "react"; 5 | import { Skeleton } from "@/components/ui/skeleton"; 6 | import { getUserProfileLoader } from "./page"; 7 | 8 | export async function ProfileName() { 9 | return ( 10 | 11 | }> 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | async function ProfileNameWrapper() { 19 | const user = await getCurrentUser(); 20 | 21 | if (!user) { 22 | return null; 23 | } 24 | 25 | const profile = await getUserProfileLoader(user.id); 26 | 27 | return ; 28 | } 29 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/security/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { invalidateUserSessions } from "@/auth"; 4 | import { authenticatedAction } from "@/lib/safe-action"; 5 | import { deleteSessionTokenCookie } from "@/lib/session"; 6 | import { redirect } from "next/navigation"; 7 | 8 | export const invalidateUserSessionsAction = authenticatedAction 9 | .createServerAction() 10 | .handler(async ({ input, ctx }) => { 11 | await invalidateUserSessions(ctx.user.id); 12 | deleteSessionTokenCookie(); 13 | redirect("/sign-in"); 14 | }); 15 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/security/logout-all-devices-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoaderButton } from "@/components/loader-button"; 4 | import { btnIconStyles, btnStyles } from "@/styles/icons"; 5 | import { LogOut } from "lucide-react"; 6 | import { useServerAction } from "zsa-react"; 7 | import { invalidateUserSessionsAction } from "./actions"; 8 | 9 | export function LogoutAllDevicesButton() { 10 | const { execute, isPending } = useServerAction(invalidateUserSessionsAction); 11 | 12 | return ( 13 | { 16 | execute(); 17 | }} 18 | isLoading={isPending} 19 | > 20 | 21 | Logout of all Sessions 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/security/page.tsx: -------------------------------------------------------------------------------- 1 | import { ConfigurationPanel } from "@/components/configuration-panel"; 2 | import { LogoutAllDevicesButton } from "./logout-all-devices-button"; 3 | 4 | export default async function SecurityPage() { 5 | return ( 6 | 7 |
8 |

9 | If you're logged in on multiple devices, you can force a logout on all 10 | of them. 11 |

12 | 13 |
14 | 15 |
16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/subscription/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import Link from "next/link"; 3 | import { env } from "@/env"; 4 | import { getUserPlanUseCase } from "@/use-cases/subscriptions"; 5 | import { ConfigurationPanel } from "@/components/configuration-panel"; 6 | import { assertAuthenticated } from "@/lib/session"; 7 | 8 | export default async function SubscriptionPage() { 9 | const user = await assertAuthenticated(); 10 | const currrentPlan = await getUserPlanUseCase(user.id); 11 | 12 | return ( 13 | currrentPlan !== "free" && ( 14 | 15 |
16 |
17 | You are currently using the{" "} 18 | {currrentPlan}{" "} 19 | plan. 20 |
21 |
You can upgrade or cancel your subscription below
22 | 31 |
32 |
33 | ) 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/settings/tabs-section.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; 4 | import { tabStyles } from "@/styles/common"; 5 | import Link from "next/link"; 6 | import { usePathname } from "next/navigation"; 7 | 8 | export function SettingsTab({ hasSubscription }: { hasSubscription: boolean }) { 9 | const path = usePathname(); 10 | const tabInUrl = path.split("/").pop(); 11 | 12 | return ( 13 |
14 |
15 | 16 | 17 | 18 | Profile 19 | 20 | 21 | 22 | Security 23 | 24 | 25 | {hasSubscription && ( 26 | 27 | 28 | Subscription 29 | 30 | 31 | )} 32 | 33 | 34 | Danger 35 | 36 | 37 | 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/app/(main)/dashboard/validation.tsx: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const schema = z.object({ 4 | name: z.string().min(1), 5 | description: z.string().min(1), 6 | }); 7 | -------------------------------------------------------------------------------- /src/app/(main)/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AUTHENTICATION_ERROR_MESSAGE } from "@/app/(main)/util"; 4 | import { Button } from "@/components/ui/button"; 5 | import { pageTitleStyles } from "@/styles/common"; 6 | import Link from "next/link"; 7 | 8 | export default function ErrorPage({ 9 | error, 10 | }: { 11 | error: Error & { digest?: string }; 12 | }) { 13 | const isAuthenticationError = error.message.includes( 14 | AUTHENTICATION_ERROR_MESSAGE 15 | ); 16 | 17 | return ( 18 |
19 | {isAuthenticationError ? ( 20 | <> 21 |

Oops! You Need to Be Logged In

22 |

To access this page, please log in first.

23 | 24 | 27 | 28 | ) : ( 29 | <> 30 |

Oops! Something went wrong

31 |

{error.message}

32 | 33 | )} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/app/(main)/invites/[token]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { env } from "@/env"; 3 | import { getCurrentUser } from "@/lib/session"; 4 | import { pageTitleStyles, pageWrapperStyles } from "@/styles/common"; 5 | import { acceptInviteUseCase } from "@/use-cases/invites"; 6 | import { Link } from "@react-email/components"; 7 | import { redirect } from "next/navigation"; 8 | 9 | export default async function InvitesPage({ 10 | params, 11 | }: { 12 | params: Promise<{ 13 | token: string; 14 | }>; 15 | }) { 16 | const { token } = await params; 17 | 18 | if (!token) { 19 | throw new Error("Invalid invite link"); 20 | } 21 | 22 | const user = await getCurrentUser(); 23 | 24 | if (user) { 25 | const groupId = await acceptInviteUseCase(user, { token }); 26 | redirect(`/dashboard/groups/${groupId}`); 27 | } 28 | 29 | return ( 30 |
31 | {!user && ( 32 | <> 33 |

Processing Invites

34 |

35 | Someone sent you an invite, but you need to first login to accept 36 | it. Click the button below to get started. 37 |

38 | 39 | 46 | 47 | )} 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { Footer } from "@/components/footer"; 3 | import { ComingSoonFooter } from "@/app/(main)/(coming-soon)/footer"; 4 | import { Header } from "@/app/(main)/_header/header"; 5 | import { appConfig } from "@/app-config"; 6 | 7 | export default async function MainLayout({ 8 | children, 9 | }: Readonly<{ 10 | children: ReactNode; 11 | }>) { 12 | return ( 13 |
14 | {appConfig.mode === "live" &&
} 15 |
{children}
16 | {appConfig.mode === "comingSoon" ? :
} 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(main)/maintenance.tsx: -------------------------------------------------------------------------------- 1 | export function Maintenance() { 2 | return ( 3 |
4 |

Maintenance

5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/app/(main)/not-found.tsx: -------------------------------------------------------------------------------- 1 | export default function NotFound() { 2 | return ( 3 |
4 |

Not Found

5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/app/(main)/notifications/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { authenticatedAction } from "@/lib/safe-action"; 4 | import { 5 | clearReadNotificationsUseCase, 6 | markAllNotificationsAsReadUseCase, 7 | markNotificationAsReadUseCase, 8 | } from "@/use-cases/notifications"; 9 | import { getNotificationLink } from "@/util/notifications"; 10 | import { revalidatePath } from "next/cache"; 11 | import { redirect } from "next/navigation"; 12 | import { z } from "zod"; 13 | 14 | export const markAllNotificationsAsReadAction = authenticatedAction 15 | .createServerAction() 16 | .handler(async ({ ctx }) => { 17 | await markAllNotificationsAsReadUseCase(ctx.user); 18 | revalidatePath("/notifications"); 19 | revalidatePath("/", "layout"); 20 | }); 21 | 22 | export const clearReadNotificationsAction = authenticatedAction 23 | .createServerAction() 24 | .handler(async ({ ctx }) => { 25 | await clearReadNotificationsUseCase(ctx.user); 26 | revalidatePath("/notifications"); 27 | }); 28 | 29 | export const readNotificationAction = authenticatedAction 30 | .createServerAction() 31 | .input(z.object({ notificationId: z.number() })) 32 | .handler(async ({ ctx, input }) => { 33 | const notification = await markNotificationAsReadUseCase( 34 | ctx.user, 35 | input.notificationId 36 | ); 37 | redirect(getNotificationLink(notification)); 38 | }); 39 | -------------------------------------------------------------------------------- /src/app/(main)/notifications/mark-read-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useServerAction } from "zsa-react"; 4 | import { markAllNotificationsAsReadAction } from "./actions"; 5 | import { LoaderButton } from "@/components/loader-button"; 6 | import { CheckCheckIcon } from "lucide-react"; 7 | import { btnIconStyles, btnStyles } from "@/styles/icons"; 8 | import { useToast } from "@/components/ui/use-toast"; 9 | 10 | export function MarkReadAllButton() { 11 | const { toast } = useToast(); 12 | const { execute, isPending } = useServerAction( 13 | markAllNotificationsAsReadAction, 14 | { 15 | onSuccess: () => { 16 | toast({ 17 | title: "Success", 18 | description: "All messages were marked as read.", 19 | }); 20 | }, 21 | } 22 | ); 23 | 24 | return ( 25 | { 28 | execute(); 29 | }} 30 | className={btnStyles} 31 | > 32 | Mark all as read 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/app/(main)/notifications/view-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Notification } from "@/db/schema"; 5 | import { getNotificationLink } from "@/util/notifications"; 6 | import Link from "next/link"; 7 | import { readNotificationAction } from "./actions"; 8 | import { useServerAction } from "zsa-react"; 9 | import { LoaderButton } from "@/components/loader-button"; 10 | 11 | export function ViewButton({ notification }: { notification: Notification }) { 12 | const { execute, isPending } = useServerAction(readNotificationAction); 13 | 14 | return notification.isRead ? ( 15 | 18 | ) : ( 19 | { 23 | execute({ notificationId: notification.id }); 24 | }} 25 | > 26 | Read 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/(main)/signed-out/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { pageTitleStyles } from "@/styles/common"; 5 | import Link from "next/link"; 6 | import { useRouter } from "next/navigation"; 7 | import { useEffect } from "react"; 8 | 9 | export default function SignedOutPage() { 10 | const router = useRouter(); 11 | useEffect(() => { 12 | router.refresh(); 13 | }, []); 14 | 15 | return ( 16 |
17 |

Successfully Signed Out

18 |

19 | You have been successfully signed out. You can now sign in to your 20 | account. 21 |

22 | 23 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/app/(main)/users/[userId]/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { authenticatedAction } from "@/lib/safe-action"; 4 | import { followUserUseCase, unfollowUserUseCase } from "@/use-cases/following"; 5 | import { revalidatePath } from "next/cache"; 6 | import { z } from "zod"; 7 | 8 | export const followUserAction = authenticatedAction 9 | .createServerAction() 10 | .input( 11 | z.object({ 12 | foreignUserId: z.number(), 13 | }) 14 | ) 15 | .handler(async ({ input: { foreignUserId }, ctx: { user } }) => { 16 | await followUserUseCase(user, foreignUserId); 17 | revalidatePath(`users/${foreignUserId}/info`); 18 | }); 19 | 20 | export const unfollowUserAction = authenticatedAction 21 | .createServerAction() 22 | .input( 23 | z.object({ 24 | foreignUserId: z.number(), 25 | }) 26 | ) 27 | .handler(async ({ input: { foreignUserId }, ctx: { user } }) => { 28 | await unfollowUserUseCase(user, foreignUserId); 29 | revalidatePath(`users/${foreignUserId}/info`); 30 | }); 31 | -------------------------------------------------------------------------------- /src/app/(main)/users/[userId]/follow-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useToast } from "@/components/ui/use-toast"; 4 | import { useServerAction } from "zsa-react"; 5 | import { followUserAction } from "./actions"; 6 | import { UserId } from "@/use-cases/types"; 7 | import { LoaderButton } from "@/components/loader-button"; 8 | import { UserPlus } from "lucide-react"; 9 | import { btnIconStyles, btnStyles } from "@/styles/icons"; 10 | 11 | export function FollowButton({ foreignUserId }: { foreignUserId: UserId }) { 12 | const { toast } = useToast(); 13 | 14 | const { execute, isPending } = useServerAction(followUserAction, { 15 | onSuccess() { 16 | toast({ 17 | title: "Success", 18 | description: "You've followed that user.", 19 | }); 20 | }, 21 | onError() { 22 | toast({ 23 | title: "Uh oh", 24 | variant: "destructive", 25 | description: "Something went wrong trying to follow that user.", 26 | }); 27 | }, 28 | }); 29 | 30 | return ( 31 | execute({ foreignUserId })} 34 | isLoading={isPending} 35 | > 36 | Follow 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/app/(main)/users/[userId]/followers/page.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 2 | import Link from "next/link"; 3 | import { Profile } from "@/db/schema"; 4 | import { getFollowersForUserUseCase } from "@/use-cases/following"; 5 | import Image from "next/image"; 6 | 7 | function FollowerCard({ profile }: { profile: Profile }) { 8 | return ( 9 |
10 |
11 | 12 | 13 | CN 14 | 15 | 16 |

{profile.displayName}

17 | 18 |
19 |
20 | ); 21 | } 22 | 23 | export default async function FollowersPage({ 24 | params, 25 | }: { 26 | params: Promise<{ userId: string }>; 27 | }) { 28 | const { userId } = await params; 29 | const userIdInt = parseInt(userId); 30 | const followers = await getFollowersForUserUseCase(userIdInt); 31 | 32 | return ( 33 |
34 | {followers.length === 0 && ( 35 |
36 | no gruops placeholder image 42 |

This user no followers

43 |
44 | )} 45 | 46 |
47 | {followers.map((follower) => ( 48 | 49 | ))} 50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/app/(main)/users/[userId]/groups/page.tsx: -------------------------------------------------------------------------------- 1 | import { GroupCard } from "@/app/(main)/dashboard/group-card"; 2 | import { getPublicGroupsByUserIdUseCase } from "@/use-cases/groups"; 3 | import Image from "next/image"; 4 | 5 | export default async function GroupsContent({ 6 | params, 7 | }: { 8 | params: Promise<{ userId: string }>; 9 | }) { 10 | const { userId } = await params; 11 | const userIdInt = parseInt(userId); 12 | const userGroups = await getPublicGroupsByUserIdUseCase(userIdInt); 13 | 14 | return ( 15 |
16 | {userGroups.length === 0 && ( 17 |
18 | no groups placeholder image 25 |

26 | This user isn't part of any groups 27 |

28 |
29 | )} 30 | 31 |
32 | {userGroups.map((group) => ( 33 | 39 | ))} 40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/(main)/users/[userId]/info/bio-view.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { extensions } from "@/app/(main)/dashboard/groups/[groupId]/info/edit-group-info-form"; 4 | import { EditorProvider } from "@tiptap/react"; 5 | 6 | export default function BioView({ bio }: { bio: string }) { 7 | return ( 8 |
9 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(main)/users/[userId]/info/page.tsx: -------------------------------------------------------------------------------- 1 | import { getUserProfileUseCase } from "@/use-cases/users"; 2 | import BioView from "./bio-view"; 3 | import Image from "next/image"; 4 | import { cardStyles } from "@/styles/common"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | export default async function InfoContent({ 8 | params, 9 | }: { 10 | params: Promise<{ userId: string }>; 11 | }) { 12 | const { userId } = await params; 13 | const userIdInt = parseInt(userId); 14 | const profile = await getUserProfileUseCase(userIdInt); 15 | 16 | return ( 17 |
18 | {!profile.bio && ( 19 |
25 | no gruops placeholder image 31 |

This user has no bio

32 |
33 | )} 34 | 35 | {profile.bio && } 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/app/(main)/users/[userId]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ProfileTabs } from "@/app/(main)/users/[userId]/profile-tabs"; 2 | import { cn } from "@/lib/utils"; 3 | import { pageWrapperStyles } from "@/styles/common"; 4 | import { ReactNode } from "react"; 5 | import { ProfileHeader } from "./profile-header"; 6 | 7 | export default async function ProfileLayout({ 8 | params, 9 | children, 10 | }: { 11 | params: Promise<{ userId: string }>; 12 | children: ReactNode; 13 | }) { 14 | const { userId } = await params; 15 | 16 | return ( 17 | <> 18 | 19 | 20 | 21 | 22 |
23 |
{children}
24 |
25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/(main)/users/[userId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default async function ProfilePage({ 4 | params, 5 | }: { 6 | params: Promise<{ userId: string }>; 7 | }) { 8 | const { userId } = await params; 9 | redirect(`/users/${userId}/info`); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/(main)/users/[userId]/posts/page.tsx: -------------------------------------------------------------------------------- 1 | import { getPublicPostsByUserUseCase } from "@/use-cases/posts"; 2 | import { UserPostCard } from "./user-post-card"; 3 | import Image from "next/image"; 4 | import { cardStyles } from "@/styles/common"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | export default async function PostsContent({ 8 | params, 9 | }: { 10 | params: Promise<{ userId: string }>; 11 | }) { 12 | const { userId } = await params; 13 | const userIdInt = parseInt(userId); 14 | const posts = await getPublicPostsByUserUseCase(userIdInt); 15 | 16 | return ( 17 |
18 | {posts.length === 0 && ( 19 |
25 | no gruops placeholder image 31 |

This user has no posts yet

32 |
33 | )} 34 |
35 | {posts.map((post) => ( 36 | 37 | ))} 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/app/(main)/users/[userId]/profile-tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; 4 | import { tabStyles } from "@/styles/common"; 5 | import { UserId } from "@/use-cases/types"; 6 | import Link from "next/link"; 7 | import { usePathname } from "next/navigation"; 8 | 9 | export function ProfileTabs({ userId }: { userId: UserId }) { 10 | const path = usePathname(); 11 | const tabInUrl = path.includes("/posts") ? "posts" : path.split("/").pop(); 12 | 13 | return ( 14 |
15 |
16 | 17 | 18 | 19 | Bio 20 | 21 | 22 | 23 | Recent Posts 24 | 25 | 26 | 27 | Groups 28 | 29 | 30 | 31 | Followers 32 | 33 | 34 | 35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/app/(main)/users/[userId]/unfollow-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useToast } from "@/components/ui/use-toast"; 4 | import { useServerAction } from "zsa-react"; 5 | import { unfollowUserAction } from "./actions"; 6 | import { UserId } from "@/use-cases/types"; 7 | import { LoaderButton } from "@/components/loader-button"; 8 | import { UserMinus } from "lucide-react"; 9 | import { btnIconStyles, btnStyles } from "@/styles/icons"; 10 | 11 | export function UnfollowButton({ foreignUserId }: { foreignUserId: UserId }) { 12 | const { toast } = useToast(); 13 | 14 | const { execute, isPending } = useServerAction(unfollowUserAction, { 15 | onSuccess() { 16 | toast({ 17 | title: "Success", 18 | description: "You've unfollowed that user.", 19 | }); 20 | }, 21 | onError() { 22 | toast({ 23 | title: "Uh oh", 24 | variant: "destructive", 25 | description: "Something went wrong trying to unfollow.", 26 | }); 27 | }, 28 | }); 29 | 30 | return ( 31 | execute({ foreignUserId })} 34 | isLoading={isPending} 35 | variant={"destructive"} 36 | > 37 | Unfollow 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/app/(main)/util.ts: -------------------------------------------------------------------------------- 1 | export const AUTHENTICATION_ERROR_MESSAGE = 2 | "You must be logged in to view this content"; 3 | 4 | export const PRIVATE_GROUP_ERROR_MESSAGE = 5 | "You do not have permission to view this group"; 6 | 7 | export const AuthenticationError = class AuthenticationError extends Error { 8 | constructor() { 9 | super(AUTHENTICATION_ERROR_MESSAGE); 10 | this.name = "AuthenticationError"; 11 | } 12 | }; 13 | 14 | export const PrivateGroupAccessError = class PrivateGroupAccessError extends Error { 15 | constructor() { 16 | super(PRIVATE_GROUP_ERROR_MESSAGE); 17 | this.name = "PrivateGroupAccessError"; 18 | } 19 | }; 20 | 21 | export const NotFoundError = class NotFoundError extends Error { 22 | constructor(message: string) { 23 | super(message); 24 | this.name = "NotFoundError"; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/app/(main)/verify-success/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { pageTitleStyles } from "@/styles/common"; 3 | import Link from "next/link"; 4 | 5 | export default function VerifySuccess() { 6 | return ( 7 |
8 |

Email Successfully Verified

9 |

10 | Your email has been successfully verified. You can now sign in to your 11 | account. 12 |

13 | 14 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/auth.tsx: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from "@/lib/session"; 2 | import { ReactNode } from "react"; 3 | 4 | export async function SignedIn({ children }: { children: ReactNode }) { 5 | const user = await getCurrentUser(); 6 | return user && <>{children}; 7 | } 8 | 9 | export async function SignedOut({ children }: { children: ReactNode }) { 10 | const user = await getCurrentUser(); 11 | return !user && <>{children}; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/breakpoint-overlay.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export function BreakpointOverlay() { 4 | if (process.env.NEXT_PUBLIC_IS_LOCAL !== "true") return null; 5 | 6 | return ( 7 |
8 | xs 9 | sm 10 | md 11 | lg 12 | xl 13 | 2xl 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/confetti.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import confetti from "canvas-confetti"; 5 | 6 | export default function Confetti() { 7 | useEffect(() => { 8 | confetti({ 9 | particleCount: 100, 10 | spread: 140, 11 | origin: { y: 0.5 }, 12 | }); 13 | }, []); 14 | 15 | return <>; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/configuration-panel.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { cardStyles } from "@/styles/common"; 3 | import { ReactNode } from "react"; 4 | 5 | export function ConfigurationPanel({ 6 | title, 7 | children, 8 | variant = "default", 9 | }: { 10 | title: string; 11 | children: ReactNode; 12 | variant?: "destructive" | "default"; 13 | }) { 14 | return ( 15 |
20 |
21 | {title} 22 |
23 |
24 |
25 |
{children}
26 |
27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/container.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | type BoundedProps = { 4 | as?: React.ElementType; 5 | className?: string; 6 | children: React.ReactNode; 7 | }; 8 | 9 | export default function Container({ 10 | as: Component = "section", 11 | className, 12 | children, 13 | ...restProps 14 | }: BoundedProps) { 15 | return ( 16 | 20 |
21 | {children} 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/delete-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoaderButton } from "@/components/loader-button"; 4 | import { 5 | AlertDialog, 6 | AlertDialogCancel, 7 | AlertDialogContent, 8 | AlertDialogDescription, 9 | AlertDialogFooter, 10 | AlertDialogHeader, 11 | AlertDialogTitle, 12 | } from "@/components/ui/alert-dialog"; 13 | 14 | export function DeleteModal({ 15 | onConfirm, 16 | description, 17 | title, 18 | isOpen, 19 | setIsOpen, 20 | confirmText = "Delete", 21 | isPending, 22 | }: { 23 | onConfirm: () => void; 24 | description: string; 25 | isOpen: boolean; 26 | setIsOpen: (open: boolean) => void; 27 | title: string; 28 | confirmText?: string; 29 | isPending: boolean; 30 | }) { 31 | return ( 32 | 33 | 34 | 35 | {title} 36 | {description} 37 | 38 | 39 | 40 | Cancel 41 | 42 | {confirmText} 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/form-group.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export function FormGroup({ children }: { children: ReactNode }) { 4 | return
{children}
; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/input-error.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export function InputError({ children }: { children: ReactNode }) { 4 | return
{children}
; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/loader-button.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2Icon } from "lucide-react"; 2 | import { Button, ButtonProps } from "@/components/ui/button"; 3 | import { cn } from "@/lib/utils"; 4 | 5 | export function LoaderButton({ 6 | children, 7 | isLoading, 8 | className, 9 | ...props 10 | }: ButtonProps & { isLoading: boolean }) { 11 | return ( 12 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/mdx.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Image from 'next/image'; 4 | import Link from 'next/link'; 5 | 6 | import { MDXRemote } from 'next-mdx-remote/rsc'; 7 | 8 | function Table({ data }: { data: any }) { 9 | let headers = data.headers.map((header: any, index: any) => ( 10 | {header} 11 | )); 12 | let rows = data.rows.map((row: any, index: any) => ( 13 | 14 | {row.map((cell: any, cellIndex: any) => ( 15 | {cell} 16 | ))} 17 | 18 | )); 19 | 20 | return ( 21 | 22 | 23 | {headers} 24 | 25 | {rows} 26 |
27 | ); 28 | } 29 | 30 | function CustomLink(props: any) { 31 | let href = props.href; 32 | 33 | if (href.startsWith('/')) { 34 | return ( 35 | 36 | {props.children} 37 | 38 | ); 39 | } 40 | 41 | if (href.startsWith('#')) { 42 | return ; 43 | } 44 | 45 | return ; 46 | } 47 | 48 | function RoundedImage(props: any) { 49 | return {props.alt}; 50 | } 51 | 52 | function Callout(props: any) { 53 | return ( 54 |
55 |
{props.emoji}
56 |
{props.children}
57 |
58 | ); 59 | } 60 | 61 | let components = { 62 | Image: RoundedImage, 63 | a: CustomLink, 64 | Callout, 65 | Table, 66 | }; 67 | 68 | export function CustomMDX(props: any) { 69 | return ( 70 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { Moon, Sun } from "lucide-react"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/page-header.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export function PageHeader({ children }: { children: ReactNode }) { 4 | return ( 5 |
6 |
{children}
7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/posthog-page-view.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname, useSearchParams } from "next/navigation"; 4 | import { useEffect } from "react"; 5 | import { usePostHog } from "posthog-js/react"; 6 | 7 | export default function PostHogPageView(): null { 8 | const pathname = usePathname(); 9 | const searchParams = useSearchParams(); 10 | const posthog = usePostHog(); 11 | // Track pageviews 12 | useEffect(() => { 13 | if (pathname && posthog) { 14 | let url = window.origin + pathname; 15 | if (searchParams.toString()) { 16 | url = url + `?${searchParams.toString()}`; 17 | } 18 | posthog.capture("$pageview", { 19 | $current_url: url, 20 | }); 21 | } 22 | }, [pathname, searchParams, posthog]); 23 | 24 | return null; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/stripe/upgrade-button/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getUser } from "@/data-access/users"; 4 | import { env } from "@/env"; 5 | import { authenticatedAction } from "@/lib/safe-action"; 6 | import { stripe } from "@/lib/stripe"; 7 | import { PublicError } from "@/use-cases/errors"; 8 | import { redirect } from "next/navigation"; 9 | import { z } from "zod"; 10 | 11 | const schema = z.object({ 12 | priceId: z.union([ 13 | z.literal(env.NEXT_PUBLIC_PRICE_ID_BASIC), 14 | z.literal(env.NEXT_PUBLIC_PRICE_ID_PREMIUM), 15 | ]), 16 | }); 17 | 18 | export const generateStripeSessionAction = authenticatedAction 19 | .createServerAction() 20 | .input(schema) 21 | .handler(async ({ input: { priceId }, ctx: { user } }) => { 22 | const fullUser = await getUser(user.id); 23 | 24 | if (!fullUser) { 25 | throw new PublicError("no user found"); 26 | } 27 | const email = fullUser.email; 28 | const userId = user.id; 29 | 30 | if (!userId) { 31 | throw new PublicError("no user id found"); 32 | } 33 | 34 | const stripeSession = await stripe.checkout.sessions.create({ 35 | success_url: `${env.HOST_NAME}/success`, 36 | cancel_url: `${env.HOST_NAME}/cancel`, 37 | payment_method_types: ["card"], 38 | customer_email: email ? email : undefined, 39 | mode: "subscription", 40 | line_items: [ 41 | { 42 | price: priceId, 43 | quantity: 1, 44 | }, 45 | ], 46 | metadata: { 47 | userId, 48 | }, 49 | }); 50 | 51 | redirect(stripeSession.url!); 52 | }); 53 | -------------------------------------------------------------------------------- /src/components/stripe/upgrade-button/checkout-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { generateStripeSessionAction } from "./actions"; 4 | import { ReactNode } from "react"; 5 | import { useServerAction } from "zsa-react"; 6 | import { LoaderButton } from "@/components/loader-button"; 7 | 8 | export function CheckoutButton({ 9 | className, 10 | children, 11 | priceId, 12 | }: { 13 | className?: string; 14 | children: ReactNode; 15 | priceId: string; 16 | }) { 17 | const { execute, isPending } = useServerAction(generateStripeSessionAction); 18 | 19 | return ( 20 |
{ 22 | e.preventDefault(); 23 | execute({ priceId }); 24 | }} 25 | > 26 | 27 | {children} 28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/submit-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Loader2Icon } from "lucide-react"; 4 | import { ReactNode } from "react"; 5 | import { useFormStatus } from "react-dom"; 6 | import { Button, ButtonProps } from "@/components/ui/button"; 7 | import { cn } from "@/lib/utils"; 8 | 9 | export function SubmitButton({ 10 | children, 11 | className, 12 | ...props 13 | }: { 14 | children: ReactNode; 15 | className?: string; 16 | } & ButtonProps) { 17 | const { pending } = useFormStatus(); 18 | 19 | return ( 20 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | success: 12 | "bg-success text-success-foreground [&>svg]:text-success-foreground", 13 | default: "bg-background dark:text-foreground", 14 | destructive: 15 | "text-black border-destructive/50 dark:bg-destructive dark:text-destructive-foreground dark:border-destructive [&>svg]:text-destructive-foreground", 16 | }, 17 | }, 18 | defaultVariants: { 19 | variant: "default", 20 | }, 21 | } 22 | ); 23 | 24 | const Alert = React.forwardRef< 25 | HTMLDivElement, 26 | React.HTMLAttributes & VariantProps 27 | >(({ className, variant, ...props }, ref) => ( 28 |
34 | )); 35 | Alert.displayName = "Alert"; 36 | 37 | const AlertTitle = React.forwardRef< 38 | HTMLParagraphElement, 39 | React.HTMLAttributes 40 | >(({ className, ...props }, ref) => ( 41 |
46 | )); 47 | AlertTitle.displayName = "AlertTitle"; 48 | 49 | const AlertDescription = React.forwardRef< 50 | HTMLParagraphElement, 51 | React.HTMLAttributes 52 | >(({ className, ...props }, ref) => ( 53 |
58 | )); 59 | AlertDescription.displayName = "AlertDescription"; 60 | 61 | export { Alert, AlertTitle, AlertDescription }; 62 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )) 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )) 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 47 | 48 | export { ScrollArea, ScrollBar } 49 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |