mirror of
https://github.com/wasp-lang/open-saas.git
synced 2025-11-20 09:06:41 +01:00
format
This commit is contained in:
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,6 +1,6 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [ wasp-lang ] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
github: [wasp-lang] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
|
||||
12
.github/workflows/blog-deployment.yml
vendored
12
.github/workflows/blog-deployment.yml
vendored
@@ -5,12 +5,12 @@ on:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'opensaas-sh/blog/**'
|
||||
- "opensaas-sh/blog/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'opensaas-sh/blog/**'
|
||||
- "opensaas-sh/blog/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./opensaas-sh/blog
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./opensaas-sh/blog
|
||||
@@ -64,10 +64,10 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@v2
|
||||
with:
|
||||
publish-dir: './opensaas-sh/blog/dist'
|
||||
publish-dir: "./opensaas-sh/blog/dist"
|
||||
production-branch: main
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
deploy-message: 'Deploy from GitHub Actions'
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
|
||||
12
.github/workflows/e2e-tests.yml
vendored
12
.github/workflows/e2e-tests.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
id: setup-node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: "22"
|
||||
|
||||
- name: Docker setup
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
run: |
|
||||
./tools/patch.sh
|
||||
|
||||
- name: '[e2e-tests] Install Node.js dependencies for Playwright tests'
|
||||
- name: "[e2e-tests] Install Node.js dependencies for Playwright tests"
|
||||
if: steps.cache-e2e-tests.outputs.cache-hit != 'true'
|
||||
working-directory: ./template
|
||||
run: |
|
||||
@@ -78,14 +78,14 @@ jobs:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}-${{ runner.os }}
|
||||
|
||||
- name: '[e2e-tests] Set up Playwright'
|
||||
- name: "[e2e-tests] Set up Playwright"
|
||||
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
|
||||
working-directory: ./template
|
||||
run: |
|
||||
cd e2e-tests
|
||||
npx playwright install --with-deps
|
||||
|
||||
- name: '[e2e-tests] Install Stripe CLI'
|
||||
- name: "[e2e-tests] Install Stripe CLI"
|
||||
run: |
|
||||
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
|
||||
@@ -95,13 +95,13 @@ jobs:
|
||||
# For Stripe webhooks to work in development, we need to run the Stripe CLI to listen for webhook events.
|
||||
# The Stripe CLI will receive the webhook events from Stripe test payments and
|
||||
# forward them to our local server so that we can test the payment flow in our e2e tests.
|
||||
- name: '[e2e-tests] Run Stripe CLI to listen for webhooks'
|
||||
- name: "[e2e-tests] Run Stripe CLI to listen for webhooks"
|
||||
env:
|
||||
STRIPE_DEVICE_NAME: ${{ secrets.STRIPE_DEVICE_NAME }}
|
||||
run: |
|
||||
stripe listen --api-key ${{ secrets.STRIPE_KEY }} --forward-to localhost:3001/payments-webhook &
|
||||
|
||||
- name: '[e2e-tests] Run Playwright tests'
|
||||
- name: "[e2e-tests] Run Playwright tests"
|
||||
env:
|
||||
# The e2e tests are testing parts of the app that need certain env vars, so we need to access them here.
|
||||
# These secretes can be set in your GitHub repo settings, e.g. https://github.com/<account>/<repo>/settings/secrets/actions
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
Thanks so much for considering contributing to Open SaaS 🙏
|
||||
|
||||
## Considerations before Contributing
|
||||
|
||||
Check if there is a GitHub issue already for the thing you would like to work on. If there is no issue yet, create a new one.
|
||||
|
||||
Let us know, in the issue, that you would like to work on it and how you plan to approach it.
|
||||
@@ -15,6 +16,7 @@ Repo is divided into two main parts: [template](/template) dir and [opensaas-sh]
|
||||
`opensaas-sh` is the app deployed to https://opensaas.sh , and is actually made with open saas! It contains a demo app and open saas docs. We keep it updated as we work on the template.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. Make sure you understand the basics of how open-saas works (check out [docs](https://docs.opensaas.sh)).
|
||||
2. Check out this repo (`main` branch) and make sure you are able to get the app in [template/app/](/template/app) running (to set it up, follow the same steps as for running a new open-saas app, as explained in the open-saas docs).
|
||||
3. Create a new git branch for your work (aka feature branch) and do your changes on it.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
## Welcome to your new SaaS App! 🎉
|
||||
|
||||
<a href="https://www.producthunt.com/products/open-saas?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-open-saas-2" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=991058&theme=neutral&t=1753776395137" alt="Open SaaS - The open-source SaaS boilerplate with superpowers! | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
https://github.com/user-attachments/assets/3856276b-23e9-455e-a564-b5f26f4f0e98
|
||||
@@ -38,6 +39,7 @@ Because we're using Wasp as the full-stack framework, we can leverage a lot of i
|
||||
- 🚀 [One-command Deploy](https://wasp.sh/docs/advanced/deployment/overview) - Easily deploy your DB, Server, & Client with one commaned to [Railway](https://railway.app) or [Fly.io](https://fly.io) via the CLI. Or deploy manually to any other hosting serivce of your choice.
|
||||
|
||||
You also get access to Wasp's diverse, helpful community if you get stuck or need help.
|
||||
|
||||
- 🤝 [Wasp Discord](https://discord.gg/aCamt5wCpS)
|
||||
|
||||
## Getting Started
|
||||
@@ -45,6 +47,7 @@ You also get access to Wasp's diverse, helpful community if you get stuck or nee
|
||||
### Simple Instructions
|
||||
|
||||
First, to install the latest version of [Wasp](https://wasp.sh/) on macOS, Linux, or Windows with WSL, run the following command:
|
||||
|
||||
```bash
|
||||
curl -sSL https://get.wasp.sh/installer.sh | sh
|
||||
```
|
||||
@@ -66,6 +69,7 @@ We've documented everything in great detail, including installation instructions
|
||||
## Getting Help & Providing Feedback
|
||||
|
||||
There are two ways to get help or provide feedback (and we try to always respond quickly!):
|
||||
|
||||
1. [Open an issue](https://github.com/wasp-lang/open-saas/issues)
|
||||
2. [Wasp Discord](https://discord.gg/aCamt5wCpS) -- please direct questions to the #🙋questions forum channel
|
||||
|
||||
@@ -74,4 +78,3 @@ There are two ways to get help or provide feedback (and we try to always respond
|
||||
Note that we've tried to get as many of the core features of a SaaS app into this template as possible, but there still might be some missing features or functionality.
|
||||
|
||||
We could always use some help tying up loose ends: contributions are welcome! Check out [CONTRIBUTING.md](/CONTRIBUTING.md) for more details.
|
||||
|
||||
|
||||
1
opensaas-sh/blog/.gitignore
vendored
1
opensaas-sh/blog/.gitignore
vendored
@@ -12,7 +12,6 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
@@ -1,100 +1,102 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import starlightBlog from 'starlight-blog';
|
||||
import starlight from "@astrojs/starlight";
|
||||
import { defineConfig } from "astro/config";
|
||||
import starlightBlog from "starlight-blog";
|
||||
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://docs.opensaas.sh',
|
||||
trailingSlash: 'always',
|
||||
site: "https://docs.opensaas.sh",
|
||||
trailingSlash: "always",
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'OpenSaaS.sh',
|
||||
description: 'Open SaaS is a free, open-source, full-stack SaaS starter kit for React + NodeJS.',
|
||||
customCss: ['./src/styles/tailwind.css'],
|
||||
title: "OpenSaaS.sh",
|
||||
description:
|
||||
"Open SaaS is a free, open-source, full-stack SaaS starter kit for React + NodeJS.",
|
||||
customCss: ["./src/styles/tailwind.css"],
|
||||
logo: {
|
||||
src: '/src/assets/logo.webp',
|
||||
alt: 'Open SaaS',
|
||||
src: "/src/assets/logo.webp",
|
||||
alt: "Open SaaS",
|
||||
},
|
||||
head: [
|
||||
{
|
||||
tag: 'script',
|
||||
tag: "script",
|
||||
attrs: {
|
||||
defer: true,
|
||||
'data-domain': 'docs.opensaas.sh',
|
||||
'data-api': 'https://opensaas.sh/wasparadocs/wasp/event',
|
||||
src: 'https://opensaas.sh/wasparadocs/wasp/script.js',
|
||||
"data-domain": "docs.opensaas.sh",
|
||||
"data-api": "https://opensaas.sh/wasparadocs/wasp/event",
|
||||
src: "https://opensaas.sh/wasparadocs/wasp/script.js",
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'script',
|
||||
tag: "script",
|
||||
attrs: {
|
||||
defer: true,
|
||||
src: '/piggy.js',
|
||||
src: "/piggy.js",
|
||||
},
|
||||
},
|
||||
],
|
||||
editLink: {
|
||||
baseUrl: 'https://github.com/wasp-lang/open-saas/edit/main/opensaas-sh/blog',
|
||||
baseUrl:
|
||||
"https://github.com/wasp-lang/open-saas/edit/main/opensaas-sh/blog",
|
||||
},
|
||||
components: {
|
||||
SiteTitle: './src/components/MyHeader.astro',
|
||||
SiteTitle: "./src/components/MyHeader.astro",
|
||||
// We customized ThemeSelect to include a "Copy URL for LLMs" button
|
||||
ThemeSelect: './src/components/MyRightNavBarItems.astro',
|
||||
Head: './src/components/HeadWithOGImage.astro',
|
||||
ThemeSelect: "./src/components/MyRightNavBarItems.astro",
|
||||
Head: "./src/components/HeadWithOGImage.astro",
|
||||
},
|
||||
social: {
|
||||
github: 'https://github.com/wasp-lang/open-saas',
|
||||
twitter: 'https://twitter.com/wasplang',
|
||||
discord: 'https://discord.gg/aCamt5wCpS',
|
||||
github: "https://github.com/wasp-lang/open-saas",
|
||||
twitter: "https://twitter.com/wasplang",
|
||||
discord: "https://discord.gg/aCamt5wCpS",
|
||||
},
|
||||
sidebar: [
|
||||
{
|
||||
label: 'Start Here',
|
||||
label: "Start Here",
|
||||
items: [
|
||||
{ label: 'Introduction', link: '/' },
|
||||
{ label: 'Getting Started', link: '/start/getting-started/' },
|
||||
{ label: 'Guided Tour', link: '/start/guided-tour/' },
|
||||
{ label: "Introduction", link: "/" },
|
||||
{ label: "Getting Started", link: "/start/getting-started/" },
|
||||
{ label: "Guided Tour", link: "/start/guided-tour/" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Guides',
|
||||
autogenerate: { directory: '/guides/' },
|
||||
label: "Guides",
|
||||
autogenerate: { directory: "/guides/" },
|
||||
},
|
||||
{
|
||||
label: 'General',
|
||||
autogenerate: { directory: '/general/' },
|
||||
label: "General",
|
||||
autogenerate: { directory: "/general/" },
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
starlightBlog({
|
||||
title: 'Blog',
|
||||
customCss: ['./src/styles/tailwind.css'],
|
||||
title: "Blog",
|
||||
customCss: ["./src/styles/tailwind.css"],
|
||||
authors: {
|
||||
vince: {
|
||||
name: 'Vince',
|
||||
title: 'Dev Rel @ Wasp',
|
||||
picture: '/CRAIG_ROCK.png', // Images in the `public` directory are supported.
|
||||
url: 'https://wasp.sh',
|
||||
name: "Vince",
|
||||
title: "Dev Rel @ Wasp",
|
||||
picture: "/CRAIG_ROCK.png", // Images in the `public` directory are supported.
|
||||
url: "https://wasp.sh",
|
||||
},
|
||||
matija: {
|
||||
name: 'Matija',
|
||||
title: 'CEO @ Wasp',
|
||||
picture: '/matija.jpeg', // Images in the `public` directory are supported.
|
||||
url: 'https://wasp.sh',
|
||||
name: "Matija",
|
||||
title: "CEO @ Wasp",
|
||||
picture: "/matija.jpeg", // Images in the `public` directory are supported.
|
||||
url: "https://wasp.sh",
|
||||
},
|
||||
milica: {
|
||||
name: 'Milica',
|
||||
title: 'Growth @ Wasp',
|
||||
picture: '/milica.jpg', // Images in the `public` directory are supported.
|
||||
url: 'https://wasp.sh',
|
||||
name: "Milica",
|
||||
title: "Growth @ Wasp",
|
||||
picture: "/milica.jpg", // Images in the `public` directory are supported.
|
||||
url: "https://wasp.sh",
|
||||
},
|
||||
martin: {
|
||||
name: 'Martin',
|
||||
title: 'CTO @ Wasp',
|
||||
picture: '/martin.jpg', // Images in the `public` directory are supported.
|
||||
url: 'https://wasp.sh',
|
||||
name: "Martin",
|
||||
title: "CTO @ Wasp",
|
||||
picture: "/martin.jpg", // Images in the `public` directory are supported.
|
||||
url: "https://wasp.sh",
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"generate-llm-files": "node ./scripts/generate-llm-files.mjs"
|
||||
"build": "astro check && astro build",
|
||||
"dev": "astro dev",
|
||||
"generate-llm-files": "node ./scripts/generate-llm-files.mjs",
|
||||
"preview": "astro preview",
|
||||
"start": "astro dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
|
||||
@@ -1,57 +1,59 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { globSync } from 'glob';
|
||||
import fm from 'front-matter';
|
||||
import fm from "front-matter";
|
||||
import fs from "fs-extra";
|
||||
import { globSync } from "glob";
|
||||
import path from "path";
|
||||
|
||||
const BLOG_ROOT = process.cwd();
|
||||
const PUBLIC_DIR = path.join(BLOG_ROOT, 'public');
|
||||
const DOCS_BASE_DIR = path.join(BLOG_ROOT, 'src/content/docs');
|
||||
const ORDERED_SOURCES = ['index.mdx', 'start', 'guides', 'general']; // Relative to DOCS_BASE_DIR
|
||||
const GITHUB_RAW_BASE_URL = 'https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/';
|
||||
const OPEN_SAAS_DOCS_BASE_URL = 'https://docs.opensaas.sh/';
|
||||
const LLM_FULL_FILENAME = 'llms-full.txt';
|
||||
const LLM_OVERVIEW_FILENAME = 'llms.txt';
|
||||
const PUBLIC_DIR = path.join(BLOG_ROOT, "public");
|
||||
const DOCS_BASE_DIR = path.join(BLOG_ROOT, "src/content/docs");
|
||||
const ORDERED_SOURCES = ["index.mdx", "start", "guides", "general"]; // Relative to DOCS_BASE_DIR
|
||||
const GITHUB_RAW_BASE_URL =
|
||||
"https://raw.githubusercontent.com/wasp-lang/open-saas/main/opensaas-sh/blog/";
|
||||
const OPEN_SAAS_DOCS_BASE_URL = "https://docs.opensaas.sh/";
|
||||
const LLM_FULL_FILENAME = "llms-full.txt";
|
||||
const LLM_OVERVIEW_FILENAME = "llms.txt";
|
||||
const OVERVIEW_FILE = path.join(PUBLIC_DIR, LLM_OVERVIEW_FILENAME);
|
||||
const FULL_CONCAT_FILE = path.join(PUBLIC_DIR, LLM_FULL_FILENAME);
|
||||
|
||||
function cleanContent(content) {
|
||||
if (!content) return '';
|
||||
if (!content) return "";
|
||||
let cleaned = content;
|
||||
// Remove lines starting with 'import ... from ...;' or just 'import ...'
|
||||
cleaned = cleaned.replace(/^import\s+.*(?:from\s+['"].*['"])?;?\s*$/gm, '');
|
||||
cleaned = cleaned.replace(/^import\s+.*(?:from\s+['"].*['"])?;?\s*$/gm, "");
|
||||
// Remove lines like {/* TODO: ... */}
|
||||
cleaned = cleaned.replace(/^\{\/\*.*\*\/\}\s*$/gm, '');
|
||||
cleaned = cleaned.replace(/^\{\/\*.*\*\/\}\s*$/gm, "");
|
||||
// Remove lines like <!-- TODO: ... -->
|
||||
cleaned = cleaned.replace(/^<!--.*-->\s*$/gm, '');
|
||||
cleaned = cleaned.replace(/^<!--.*-->\s*$/gm, "");
|
||||
// Remove <Image ... /> tags
|
||||
cleaned = cleaned.replace(/<Image[^>]*?\/>/g, '');
|
||||
cleaned = cleaned.replace(/<Image[^>]*?\/>/g, "");
|
||||
// Remove <HiddenLLMHelper /> tag
|
||||
cleaned = cleaned.replace(/<HiddenLLMHelper\s*\/>/g, '');
|
||||
cleaned = cleaned.replace(/<HiddenLLMHelper\s*\/>/g, "");
|
||||
// Remove Emojis using specific ranges (less likely to remove digits)
|
||||
cleaned = cleaned.replace(
|
||||
/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA70}-\u{1FAFF}]/gu,
|
||||
''
|
||||
"",
|
||||
);
|
||||
cleaned = cleaned.replace(/\u00A0/g, ' ');
|
||||
cleaned = cleaned.replace(/\u00A0/g, " ");
|
||||
// Remove Box Drawing Characters (single and double line)
|
||||
cleaned = cleaned.replace(/[│├└─╔═╗║╚╝]/g, '');
|
||||
cleaned = cleaned.replace(/[│├└─╔═╗║╚╝]/g, "");
|
||||
// Remove more than two line breaks
|
||||
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
|
||||
cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
async function generateFiles() {
|
||||
console.log('Starting LLM file generation...');
|
||||
console.log("Starting LLM file generation...");
|
||||
|
||||
const introSummary = '> Open SaaS is a free, open-source, full-stack starter kit for building SaaS applications quickly. It leverages the [Wasp](https://wasp.sh/docs) framework (React, Node.js, Prisma) and integrates with various services like Stripe/Lemon Squeezy, OpenAI API, AWS S3, and different email providers.';
|
||||
const introSummary =
|
||||
"> Open SaaS is a free, open-source, full-stack starter kit for building SaaS applications quickly. It leverages the [Wasp](https://wasp.sh/docs) framework (React, Node.js, Prisma) and integrates with various services like Stripe/Lemon Squeezy, OpenAI API, AWS S3, and different email providers.";
|
||||
|
||||
let orderedSourceFiles = [];
|
||||
const fullConcatenatedFileUrl = OPEN_SAAS_DOCS_BASE_URL + LLM_FULL_FILENAME;
|
||||
let overviewContent = `# Open SaaS Documentation for LLMs\n\n${introSummary}\n\n## Full Documentation\n - [View Complete LLM-formatted Open SaaS Documentation](${fullConcatenatedFileUrl})\n\n## Individual documentation sections and guides:\n`;
|
||||
let fullConcatContent = '';
|
||||
let fullConcatContent = "";
|
||||
|
||||
console.log('Gathering source files...');
|
||||
console.log("Gathering source files...");
|
||||
for (const sourceItem of ORDERED_SOURCES) {
|
||||
const itemPath = path.join(DOCS_BASE_DIR, sourceItem);
|
||||
try {
|
||||
@@ -60,56 +62,66 @@ async function generateFiles() {
|
||||
orderedSourceFiles.push(itemPath);
|
||||
console.log(` Added file: ${sourceItem}`);
|
||||
} else if (stats.isDirectory()) {
|
||||
const files = globSync(path.join(itemPath, '**/*.{md,mdx}').replace(/\\/g, '/'), { nodir: true }).sort();
|
||||
const files = globSync(
|
||||
path.join(itemPath, "**/*.{md,mdx}").replace(/\\/g, "/"),
|
||||
{ nodir: true },
|
||||
).sort();
|
||||
orderedSourceFiles.push(...files);
|
||||
console.log(` Added ${files.length} files from directory: ${sourceItem}`);
|
||||
console.log(
|
||||
` Added ${files.length} files from directory: ${sourceItem}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
console.warn(`Warning: Source item not found: ${itemPath}`);
|
||||
if (error.code === "ENOENT") {
|
||||
console.warn(`Warning: Source item not found: ${itemPath}`);
|
||||
} else {
|
||||
console.warn(`Warning: Could not access source item: ${itemPath} - ${error.message}`);
|
||||
console.warn(
|
||||
`Warning: Could not access source item: ${itemPath} - ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`Total source files found: ${orderedSourceFiles.length}`);
|
||||
|
||||
console.log('Processing files...');
|
||||
console.log("Processing files...");
|
||||
for (const sourceFilePath of orderedSourceFiles) {
|
||||
try {
|
||||
const rawContent = await fs.readFile(sourceFilePath, 'utf8');
|
||||
const rawContent = await fs.readFile(sourceFilePath, "utf8");
|
||||
const { attributes, body } = fm(rawContent);
|
||||
|
||||
const processedContent = cleanContent(body);
|
||||
|
||||
const title = attributes.title || path.basename(sourceFilePath, path.extname(sourceFilePath));
|
||||
const title =
|
||||
attributes.title ||
|
||||
path.basename(sourceFilePath, path.extname(sourceFilePath));
|
||||
|
||||
const relativeSourcePathForGithub = path.relative(BLOG_ROOT, sourceFilePath).replace(/\\/g, '/');
|
||||
const relativeSourcePathForGithub = path
|
||||
.relative(BLOG_ROOT, sourceFilePath)
|
||||
.replace(/\\/g, "/");
|
||||
const githubRawUrl = GITHUB_RAW_BASE_URL + relativeSourcePathForGithub;
|
||||
overviewContent += `- [${title}](${githubRawUrl})\n`;
|
||||
|
||||
fullConcatContent += `# ${title}\n\n${processedContent}\n\n---\n\n`;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${sourceFilePath}:`, error);
|
||||
console.error(`Error processing file ${sourceFilePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Writing output files to public/ ...');
|
||||
console.log("Writing output files to public/ ...");
|
||||
try {
|
||||
await fs.writeFile(OVERVIEW_FILE, overviewContent.trim(), 'utf8');
|
||||
await fs.writeFile(OVERVIEW_FILE, overviewContent.trim(), "utf8");
|
||||
console.log(`Generated overview file: ${OVERVIEW_FILE}`);
|
||||
|
||||
await fs.writeFile(FULL_CONCAT_FILE, fullConcatContent.trim(), 'utf8');
|
||||
await fs.writeFile(FULL_CONCAT_FILE, fullConcatContent.trim(), "utf8");
|
||||
console.log(`Generated full concatenated file: ${FULL_CONCAT_FILE}`);
|
||||
} catch (error) {
|
||||
console.error('Error writing output files:', error);
|
||||
console.error("Error writing output files:", error);
|
||||
}
|
||||
|
||||
console.log('LLM file generation complete.');
|
||||
console.log("LLM file generation complete.");
|
||||
}
|
||||
|
||||
generateFiles().catch(error => {
|
||||
console.error('Unhandled error during LLM file generation:', error);
|
||||
generateFiles().catch((error) => {
|
||||
console.error("Unhandled error during LLM file generation:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,16 +1,24 @@
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
export const BANNER_PATH = '/banner-images';
|
||||
export const BANNER_PATH = "/banner-images";
|
||||
|
||||
export const DEFAULT_BANNER_IMAGE = 'opensaas.webp';
|
||||
export const DEFAULT_BANNER_IMAGE = "opensaas.webp";
|
||||
|
||||
export const getBannerImageFilename = ({ path }: { path: string }) =>
|
||||
path.replace(/.*\//, '').replace(/\.\w+$/, '.webp');
|
||||
path.replace(/.*\//, "").replace(/\.\w+$/, ".webp");
|
||||
|
||||
export const checkBannerImageExists = ({ bannerImageFileName }: { bannerImageFileName: string }) => {
|
||||
export const checkBannerImageExists = ({
|
||||
bannerImageFileName,
|
||||
}: {
|
||||
bannerImageFileName: string;
|
||||
}) => {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const imagePath = path.join(__dirname, `../../public/${BANNER_PATH}`, bannerImageFileName);
|
||||
const imagePath = path.join(
|
||||
__dirname,
|
||||
`../../public/${BANNER_PATH}`,
|
||||
bannerImageFileName,
|
||||
);
|
||||
return existsSync(imagePath);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { i18nSchema, docsSchema } from '@astrojs/starlight/schema';
|
||||
import { blogSchema } from 'starlight-blog/schema';
|
||||
import { z } from 'astro:content';
|
||||
import { docsSchema, i18nSchema } from "@astrojs/starlight/schema";
|
||||
import { defineCollection, z } from "astro:content";
|
||||
import { blogSchema } from "starlight-blog/schema";
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({
|
||||
@@ -16,5 +15,5 @@ export const collections = {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
i18n: defineCollection({ type: 'data', schema: i18nSchema() }),
|
||||
i18n: defineCollection({ type: "data", schema: i18nSchema() }),
|
||||
};
|
||||
16
opensaas-sh/blog/src/virtual.d.ts
vendored
16
opensaas-sh/blog/src/virtual.d.ts
vendored
@@ -1,22 +1,22 @@
|
||||
declare module 'virtual:starlight-blog-config' {
|
||||
const StarlightBlogConfig: import('./libs/config').StarlightBlogConfig;
|
||||
declare module "virtual:starlight-blog-config" {
|
||||
const StarlightBlogConfig: import("./libs/config").StarlightBlogConfig;
|
||||
|
||||
export default StarlightBlogConfig;
|
||||
}
|
||||
declare module 'virtual:starlight/user-config' {
|
||||
const Config: import('@astrojs/starlight/types').StarlightConfig;
|
||||
declare module "virtual:starlight/user-config" {
|
||||
const Config: import("@astrojs/starlight/types").StarlightConfig;
|
||||
|
||||
export default Config;
|
||||
}
|
||||
declare module 'virtual:starlight/user-images' {
|
||||
type ImageMetadata = import('astro').ImageMetadata;
|
||||
declare module "virtual:starlight/user-images" {
|
||||
type ImageMetadata = import("astro").ImageMetadata;
|
||||
export const logos: {
|
||||
dark?: ImageMetadata;
|
||||
light?: ImageMetadata;
|
||||
};
|
||||
}
|
||||
declare module 'virtual:astro-config' {
|
||||
const Config: import('@astrojs/types').Config;
|
||||
declare module "virtual:astro-config" {
|
||||
const Config: import("@astrojs/types").Config;
|
||||
|
||||
export default Config;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import starlightPlugin from "@astrojs/starlight-tailwind";
|
||||
import colors from "tailwindcss/colors";
|
||||
|
||||
const yellow = colors.yellow
|
||||
const gray = colors.gray
|
||||
const yellow = colors.yellow;
|
||||
const gray = colors.gray;
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
@@ -10,7 +10,8 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: yellow, gray
|
||||
accent: yellow,
|
||||
gray,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
TOOLS_DIR=$(dirname "$(realpath "$0")") # Assumes this script is in `tools/`.
|
||||
TOOLS_DIR=$(dirname "$(realpath "$0")") # Assumes this script is in `tools/`.
|
||||
cd "${TOOLS_DIR}" && cd ../..
|
||||
|
||||
rm -rf opensaas-sh/app_diff
|
||||
|
||||
@@ -8,28 +8,28 @@
|
||||
# Determine the patch command to use based on OS
|
||||
PATCH_CMD="patch"
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
# On macOS, require gpatch to be installed
|
||||
if command -v gpatch &> /dev/null; then
|
||||
PATCH_CMD="gpatch"
|
||||
else
|
||||
echo "Error: GNU patch (gpatch) not found. On MacOS, this script requires GNU patch."
|
||||
echo "Install it with: brew install gpatch"
|
||||
exit 1
|
||||
fi
|
||||
# On macOS, require gpatch to be installed
|
||||
if command -v gpatch &> /dev/null; then
|
||||
PATCH_CMD="gpatch"
|
||||
else
|
||||
echo "Error: GNU patch (gpatch) not found. On MacOS, this script requires GNU patch."
|
||||
echo "Install it with: brew install gpatch"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# List all the source files in the specified dir.
|
||||
# "Source" files are any files that are not gitignored.
|
||||
list_source_files() {
|
||||
local dir=$1
|
||||
(cd "${dir}" && git ls-files --cached --others --exclude-standard | sort)
|
||||
local dir=$1
|
||||
(cd "${dir}" && git ls-files --cached --others --exclude-standard | sort)
|
||||
}
|
||||
|
||||
# Check if the required arguments are provided.
|
||||
if [ "$#" -ne 3 ]; then
|
||||
echo "Usage: $0 <BASE_DIR> <DERIVED_DIR> <ACTION>"
|
||||
echo "<ACTION> should be either 'diff' to get the diff between the specified dirs or 'patch' to apply such existing diff onto base dir."
|
||||
exit 1
|
||||
echo "Usage: $0 <BASE_DIR> <DERIVED_DIR> <ACTION>"
|
||||
echo "<ACTION> should be either 'diff' to get the diff between the specified dirs or 'patch' to apply such existing diff onto base dir."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASE_DIR=$1
|
||||
@@ -41,41 +41,41 @@ DIFF_DIR_DELETIONS="${DIFF_DIR}/deletions"
|
||||
|
||||
# Based on base dir and derived dir, creates a diff dir that contains the diff between the two dirs.
|
||||
recreate_diff_dir() {
|
||||
mkdir -p "${DIFF_DIR}"
|
||||
mkdir -p "${DIFF_DIR}"
|
||||
|
||||
# List all the source files in the base and derived dirs. We skip gitignored files.
|
||||
local BASE_FILES
|
||||
BASE_FILES=$(list_source_files "${BASE_DIR}") # File paths relative to the base dir.
|
||||
local DERIVED_FILES
|
||||
DERIVED_FILES=$(list_source_files "${DERIVED_DIR}") # File paths relative to the derived dir.
|
||||
# List all the source files in the base and derived dirs. We skip gitignored files.
|
||||
local BASE_FILES
|
||||
BASE_FILES=$(list_source_files "${BASE_DIR}") # File paths relative to the base dir.
|
||||
local DERIVED_FILES
|
||||
DERIVED_FILES=$(list_source_files "${DERIVED_DIR}") # File paths relative to the derived dir.
|
||||
|
||||
# For each source file in the derived dir, generate a .diff file between it and the
|
||||
# corresponding source file in the base dir.
|
||||
while IFS= read -r filepath; do
|
||||
local derivedFilepath="${DERIVED_DIR}/${filepath}"
|
||||
local baseFilepath="${BASE_DIR}/${filepath}"
|
||||
# For each source file in the derived dir, generate a .diff file between it and the
|
||||
# corresponding source file in the base dir.
|
||||
while IFS= read -r filepath; do
|
||||
local derivedFilepath="${DERIVED_DIR}/${filepath}"
|
||||
local baseFilepath="${BASE_DIR}/${filepath}"
|
||||
|
||||
local filepathToBeUsedAsBase="${baseFilepath}"
|
||||
# If the file is not one of the source files in base dir (e.g. is gitignored or doesn't exist),
|
||||
# then we set it to /dev/null to indicate it doesn't exist for our purposes.
|
||||
if ! echo "${BASE_FILES}" | grep -q "^${filepath}$"; then
|
||||
filepathToBeUsedAsBase="/dev/null"
|
||||
fi
|
||||
local filepathToBeUsedAsBase="${baseFilepath}"
|
||||
# If the file is not one of the source files in base dir (e.g. is gitignored or doesn't exist),
|
||||
# then we set it to /dev/null to indicate it doesn't exist for our purposes.
|
||||
if ! echo "${BASE_FILES}" | grep -q "^${filepath}$"; then
|
||||
filepathToBeUsedAsBase="/dev/null"
|
||||
fi
|
||||
|
||||
local DIFF_OUTPUT
|
||||
DIFF_OUTPUT=$(diff -Nu --label "${baseFilepath}" --label "${derivedFilepath}" "${filepathToBeUsedAsBase}" "${derivedFilepath}")
|
||||
if [ $? -eq 1 ]; then
|
||||
mkdir -p "${DIFF_DIR}/$(dirname "${filepath}")"
|
||||
echo "${DIFF_OUTPUT}" > "${DIFF_DIR}/${filepath}.diff"
|
||||
echo "Generated ${DIFF_DIR}/${filepath}.diff"
|
||||
fi
|
||||
done <<< "${DERIVED_FILES}"
|
||||
local DIFF_OUTPUT
|
||||
DIFF_OUTPUT=$(diff -Nu --label "${baseFilepath}" --label "${derivedFilepath}" "${filepathToBeUsedAsBase}" "${derivedFilepath}")
|
||||
if [ $? -eq 1 ]; then
|
||||
mkdir -p "${DIFF_DIR}/$(dirname "${filepath}")"
|
||||
echo "${DIFF_OUTPUT}" > "${DIFF_DIR}/${filepath}.diff"
|
||||
echo "Generated ${DIFF_DIR}/${filepath}.diff"
|
||||
fi
|
||||
done <<< "${DERIVED_FILES}"
|
||||
|
||||
local FILES_ONLY_IN_BASE
|
||||
FILES_ONLY_IN_BASE=$(comm -23 <(echo "${BASE_FILES}") <(echo "${DERIVED_FILES}"))
|
||||
echo "${FILES_ONLY_IN_BASE}" > "${DIFF_DIR_DELETIONS}"
|
||||
local FILES_ONLY_IN_BASE
|
||||
FILES_ONLY_IN_BASE=$(comm -23 <(echo "${BASE_FILES}") <(echo "${DERIVED_FILES}"))
|
||||
echo "${FILES_ONLY_IN_BASE}" > "${DIFF_DIR_DELETIONS}"
|
||||
|
||||
echo "DONE: generated ${DIFF_DIR}/"
|
||||
echo "DONE: generated ${DIFF_DIR}/"
|
||||
}
|
||||
|
||||
RED_COLOR='\033[0;31m'
|
||||
@@ -84,76 +84,76 @@ RESET_COLOR='\033[0m'
|
||||
|
||||
# Patches the diff dir onto the base dir to get the derived dir.
|
||||
recreate_derived_dir() {
|
||||
mkdir -p "${DERIVED_DIR}"
|
||||
mkdir -p "${DERIVED_DIR}"
|
||||
|
||||
local BASE_FILES
|
||||
BASE_FILES=$(list_source_files "${BASE_DIR}") # File paths relative to the base dir.
|
||||
local BASE_FILES
|
||||
BASE_FILES=$(list_source_files "${BASE_DIR}") # File paths relative to the base dir.
|
||||
|
||||
# Copy all the source files from the base dir over to the derived dir.
|
||||
while IFS= read -r filepath; do
|
||||
mkdir -p "${DERIVED_DIR}/$(dirname ${filepath})"
|
||||
cp "${BASE_DIR}/${filepath}" "${DERIVED_DIR}/${filepath}"
|
||||
done <<< "${BASE_FILES}"
|
||||
# Copy all the source files from the base dir over to the derived dir.
|
||||
while IFS= read -r filepath; do
|
||||
mkdir -p "${DERIVED_DIR}/$(dirname ${filepath})"
|
||||
cp "${BASE_DIR}/${filepath}" "${DERIVED_DIR}/${filepath}"
|
||||
done <<< "${BASE_FILES}"
|
||||
|
||||
# For each .diff file in diff dir, apply the patch to the corresponding base file in the derived dir.
|
||||
#local num_patches_failed
|
||||
local num_patches_failed=0
|
||||
while IFS= read -r diff_filepath; do
|
||||
local derived_filepath
|
||||
derived_filepath="${diff_filepath#${DIFF_DIR}/}"
|
||||
derived_filepath="${derived_filepath%.diff}"
|
||||
# For each .diff file in diff dir, apply the patch to the corresponding base file in the derived dir.
|
||||
#local num_patches_failed
|
||||
local num_patches_failed=0
|
||||
while IFS= read -r diff_filepath; do
|
||||
local derived_filepath
|
||||
derived_filepath="${diff_filepath#${DIFF_DIR}/}"
|
||||
derived_filepath="${derived_filepath%.diff}"
|
||||
|
||||
local patch_output
|
||||
local patch_exit_code
|
||||
patch_output=$("${PATCH_CMD}" --no-backup-if-mismatch --merge "${DERIVED_DIR}/${derived_filepath}" < "${diff_filepath}")
|
||||
patch_exit_code=$?
|
||||
if [ ${patch_exit_code} -eq 0 ]; then
|
||||
echo "${patch_output}"
|
||||
echo -e "${GREEN_COLOR}[OK]${RESET_COLOR}"
|
||||
else
|
||||
echo "${patch_output}"
|
||||
echo -e "${RED_COLOR}[Failed with exit code ${patch_exit_code}]${RESET_COLOR}"
|
||||
num_patches_failed=$((num_patches_failed + 1))
|
||||
fi
|
||||
echo ""
|
||||
done < <(find "${DIFF_DIR}" -name "*.diff")
|
||||
|
||||
# Delete any files that exist in the base dir but shouldn't exist in the derived dir.
|
||||
# TODO: also allow deletion of dirs.
|
||||
if [ -f "${DIFF_DIR_DELETIONS}" ]; then
|
||||
while IFS= read -r filepath; do
|
||||
local derived_dir_filepath
|
||||
local rm_exit_code
|
||||
derived_dir_filepath="${DERIVED_DIR}/${filepath}"
|
||||
rm "${derived_dir_filepath}"
|
||||
rm_exit_code=$?
|
||||
if [ ${rm_exit_code} -eq 0 ]; then
|
||||
echo "Deleted ${derived_dir_filepath}"
|
||||
echo -e "${GREEN_COLOR}[OK]${RESET_COLOR}"
|
||||
else
|
||||
echo "Failed to delete ${derived_dir_filepath}"
|
||||
echo -e "${RED_COLOR}[Failed with exit code ${rm_exit_code}]${RESET_COLOR}"
|
||||
fi
|
||||
echo ""
|
||||
done < "${DIFF_DIR_DELETIONS}"
|
||||
fi
|
||||
|
||||
echo "DONE: generated ${DERIVED_DIR}/"
|
||||
|
||||
if [ ${num_patches_failed} -gt 0 ]; then
|
||||
echo -e "${RED_COLOR}${num_patches_failed} patches failed, look into generated files for merge conflicts.${RESET_COLOR}"
|
||||
exit 1;
|
||||
local patch_output
|
||||
local patch_exit_code
|
||||
patch_output=$("${PATCH_CMD}" --no-backup-if-mismatch --merge "${DERIVED_DIR}/${derived_filepath}" < "${diff_filepath}")
|
||||
patch_exit_code=$?
|
||||
if [ ${patch_exit_code} -eq 0 ]; then
|
||||
echo "${patch_output}"
|
||||
echo -e "${GREEN_COLOR}[OK]${RESET_COLOR}"
|
||||
else
|
||||
echo -e "${GREEN_COLOR}All patches successfully applied.${RESET_COLOR}"
|
||||
echo "${patch_output}"
|
||||
echo -e "${RED_COLOR}[Failed with exit code ${patch_exit_code}]${RESET_COLOR}"
|
||||
num_patches_failed=$((num_patches_failed + 1))
|
||||
fi
|
||||
echo ""
|
||||
done < <(find "${DIFF_DIR}" -name "*.diff")
|
||||
|
||||
# Delete any files that exist in the base dir but shouldn't exist in the derived dir.
|
||||
# TODO: also allow deletion of dirs.
|
||||
if [ -f "${DIFF_DIR_DELETIONS}" ]; then
|
||||
while IFS= read -r filepath; do
|
||||
local derived_dir_filepath
|
||||
local rm_exit_code
|
||||
derived_dir_filepath="${DERIVED_DIR}/${filepath}"
|
||||
rm "${derived_dir_filepath}"
|
||||
rm_exit_code=$?
|
||||
if [ ${rm_exit_code} -eq 0 ]; then
|
||||
echo "Deleted ${derived_dir_filepath}"
|
||||
echo -e "${GREEN_COLOR}[OK]${RESET_COLOR}"
|
||||
else
|
||||
echo "Failed to delete ${derived_dir_filepath}"
|
||||
echo -e "${RED_COLOR}[Failed with exit code ${rm_exit_code}]${RESET_COLOR}"
|
||||
fi
|
||||
echo ""
|
||||
done < "${DIFF_DIR_DELETIONS}"
|
||||
fi
|
||||
|
||||
echo "DONE: generated ${DERIVED_DIR}/"
|
||||
|
||||
if [ ${num_patches_failed} -gt 0 ]; then
|
||||
echo -e "${RED_COLOR}${num_patches_failed} patches failed, look into generated files for merge conflicts.${RESET_COLOR}"
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN_COLOR}All patches successfully applied.${RESET_COLOR}"
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
if [ "$ACTION" == "diff" ]; then
|
||||
recreate_diff_dir
|
||||
recreate_diff_dir
|
||||
elif [ "$ACTION" == "patch" ]; then
|
||||
recreate_derived_dir
|
||||
recreate_derived_dir
|
||||
else
|
||||
echo "Invalid action specified. Use 'diff' to get a diff between specified dirs or 'patch' to patch the existing diff onto base dir."
|
||||
exit 1
|
||||
echo "Invalid action specified. Use 'diff' to get a diff between specified dirs or 'patch' to patch the existing diff onto base dir."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
TOOLS_DIR=$(dirname "$(realpath "$0")") # Assumes this script is in `tools/`.
|
||||
TOOLS_DIR=$(dirname "$(realpath "$0")") # Assumes this script is in `tools/`.
|
||||
cd "${TOOLS_DIR}" && cd ../..
|
||||
|
||||
# Removes all files except for some gitignored files that we don't want to bother regenerating each time,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# <YOUR_APP_NAME>
|
||||
|
||||
This project is based on [OpenSaas](https://opensaas.sh) template and consists of three main dirs:
|
||||
|
||||
1. `app` - Your web app, built with [Wasp](https://wasp.sh).
|
||||
2. `e2e-tests` - [Playwright](https://playwright.dev/) tests for your Wasp web app.
|
||||
3. `blog` - Your blog / docs, built with [Astro](https://docs.astro.build) based on [Starlight](https://starlight.astro.build/) template.
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
### PRD / initial prompt
|
||||
|
||||
I want to create a `<insert-type-of-app-here>` app with the current SaaS boilerplate template project I'm in which uses Wasp and already has payment processing, AWS S3 file upload, a landing page, an admin dashboard, and authentication already setup. Leveraging Wasp's full-stack features (such as Auth), let's build the app based on the following spec:
|
||||
- `<insert-feature-spec-here>`
|
||||
- `<insert-feature-spec-here>`
|
||||
- `<insert-feature-spec-here>`
|
||||
|
||||
- `<insert-feature-spec-here>`
|
||||
- `<insert-feature-spec-here>`
|
||||
- `<insert-feature-spec-here>`
|
||||
|
||||
With this in mind, I want you to first evaluate the project template and think about a few possible PRD approaches before landing on the best one. Provide reasoning why this would be the best approach. Remember we're using Wasp, a full-stack framework with batteries included, that can do some of the heavy lifting for us, and we want to use a modified vertical slice implementation approach for LLM-assisted coding so we can start with basic implementations of features first, and add on complexity from there.
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ This template includes [ShadCN UI](https://ui.shadcn.com/) v2 for beautiful, acc
|
||||
## Development
|
||||
|
||||
### Running locally
|
||||
- Make sure you have the `.env.client` and `.env.server` files with correct dev values in the root of the project.
|
||||
- Run the database with `wasp start db` and leave it running.
|
||||
- Run `wasp start` and leave it running.
|
||||
- [OPTIONAL]: If this is the first time starting the app, or you've just made changes to your entities/prisma schema, also run `wasp db migrate-dev`.
|
||||
|
||||
- Make sure you have the `.env.client` and `.env.server` files with correct dev values in the root of the project.
|
||||
- Run the database with `wasp start db` and leave it running.
|
||||
- Run `wasp start` and leave it running.
|
||||
- [OPTIONAL]: If this is the first time starting the app, or you've just made changes to your entities/prisma schema, also run `wasp db migrate-dev`.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import { getDailyStats, useQuery } from 'wasp/client/operations';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import RevenueAndProfitChart from './RevenueAndProfitChart';
|
||||
import SourcesTable from './SourcesTable';
|
||||
import TotalPageViewsCard from './TotalPageViewsCard';
|
||||
import TotalPayingUsersCard from './TotalPayingUsersCard';
|
||||
import TotalRevenueCard from './TotalRevenueCard';
|
||||
import TotalSignupsCard from './TotalSignupsCard';
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import { getDailyStats, useQuery } from "wasp/client/operations";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import DefaultLayout from "../../layout/DefaultLayout";
|
||||
import RevenueAndProfitChart from "./RevenueAndProfitChart";
|
||||
import SourcesTable from "./SourcesTable";
|
||||
import TotalPageViewsCard from "./TotalPageViewsCard";
|
||||
import TotalPayingUsersCard from "./TotalPayingUsersCard";
|
||||
import TotalRevenueCard from "./TotalRevenueCard";
|
||||
import TotalSignupsCard from "./TotalSignupsCard";
|
||||
|
||||
const Dashboard = ({ user }: { user: AuthUser }) => {
|
||||
const { data: stats, isLoading, error } = useQuery(getDailyStats);
|
||||
@@ -15,11 +15,11 @@ const Dashboard = ({ user }: { user: AuthUser }) => {
|
||||
if (error) {
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='rounded-lg bg-card p-8 shadow-lg'>
|
||||
<p className='text-2xl font-bold text-red-500'>Error</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{error.message || 'Something went wrong while fetching stats.'}
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="bg-card rounded-lg p-8 shadow-lg">
|
||||
<p className="text-2xl font-bold text-red-500">Error</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{error.message || "Something went wrong while fetching stats."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,40 +29,53 @@ const Dashboard = ({ user }: { user: AuthUser }) => {
|
||||
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<div className='relative'>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn({
|
||||
'opacity-25': !stats,
|
||||
"opacity-25": !stats,
|
||||
})}
|
||||
>
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-4 2xl:gap-7.5'>
|
||||
<div className="2xl:gap-7.5 grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-4">
|
||||
<TotalPageViewsCard
|
||||
totalPageViews={stats?.dailyStats.totalViews}
|
||||
prevDayViewsChangePercent={stats?.dailyStats.prevDayViewsChangePercent}
|
||||
prevDayViewsChangePercent={
|
||||
stats?.dailyStats.prevDayViewsChangePercent
|
||||
}
|
||||
/>
|
||||
<TotalRevenueCard
|
||||
dailyStats={stats?.dailyStats}
|
||||
weeklyStats={stats?.weeklyStats}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<TotalPayingUsersCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
|
||||
<TotalSignupsCard dailyStats={stats?.dailyStats} isLoading={isLoading} />
|
||||
<TotalPayingUsersCard
|
||||
dailyStats={stats?.dailyStats}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<TotalSignupsCard
|
||||
dailyStats={stats?.dailyStats}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 grid grid-cols-12 gap-4 md:mt-6 md:gap-6 2xl:mt-7.5 2xl:gap-7.5'>
|
||||
<RevenueAndProfitChart weeklyStats={stats?.weeklyStats} isLoading={isLoading} />
|
||||
<div className="2xl:mt-7.5 2xl:gap-7.5 mt-4 grid grid-cols-12 gap-4 md:mt-6 md:gap-6">
|
||||
<RevenueAndProfitChart
|
||||
weeklyStats={stats?.weeklyStats}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<div className='col-span-12 xl:col-span-8'>
|
||||
<div className="col-span-12 xl:col-span-8">
|
||||
<SourcesTable sources={stats?.dailyStats?.sources} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!stats && (
|
||||
<div className='absolute inset-0 flex items-start justify-center bg-background/50'>
|
||||
<div className='rounded-lg bg-card p-8 shadow-lg'>
|
||||
<p className='text-2xl font-bold text-foreground'>No daily stats generated yet</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
<div className="bg-background/50 absolute inset-0 flex items-start justify-center">
|
||||
<div className="bg-card rounded-lg p-8 shadow-lg">
|
||||
<p className="text-foreground text-2xl font-bold">
|
||||
No daily stats generated yet
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Stats will appear here once the daily stats job has run
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { ApexOptions } from 'apexcharts';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import ReactApexChart from 'react-apexcharts';
|
||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
||||
import { ApexOptions } from "apexcharts";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import ReactApexChart from "react-apexcharts";
|
||||
import { type DailyStatsProps } from "../../../analytics/stats";
|
||||
|
||||
const options: ApexOptions = {
|
||||
legend: {
|
||||
show: false,
|
||||
position: 'top',
|
||||
horizontalAlign: 'left',
|
||||
position: "top",
|
||||
horizontalAlign: "left",
|
||||
},
|
||||
colors: ['#3C50E0', '#80CAEE'],
|
||||
colors: ["#3C50E0", "#80CAEE"],
|
||||
chart: {
|
||||
fontFamily: 'Satoshi, sans-serif',
|
||||
fontFamily: "Satoshi, sans-serif",
|
||||
height: 335,
|
||||
type: 'area',
|
||||
type: "area",
|
||||
dropShadow: {
|
||||
enabled: true,
|
||||
color: '#623CEA14',
|
||||
color: "#623CEA14",
|
||||
top: 10,
|
||||
blur: 4,
|
||||
left: 0,
|
||||
@@ -47,7 +47,7 @@ const options: ApexOptions = {
|
||||
],
|
||||
stroke: {
|
||||
width: [2, 2],
|
||||
curve: 'straight',
|
||||
curve: "straight",
|
||||
},
|
||||
// labels: {
|
||||
// show: false,
|
||||
@@ -70,8 +70,8 @@ const options: ApexOptions = {
|
||||
},
|
||||
markers: {
|
||||
size: 4,
|
||||
colors: '#fff',
|
||||
strokeColors: ['#3056D3', '#80CAEE'],
|
||||
colors: "#fff",
|
||||
strokeColors: ["#3056D3", "#80CAEE"],
|
||||
strokeWidth: 3,
|
||||
strokeOpacity: 0.9,
|
||||
strokeDashArray: 0,
|
||||
@@ -83,7 +83,7 @@ const options: ApexOptions = {
|
||||
},
|
||||
},
|
||||
xaxis: {
|
||||
type: 'category',
|
||||
type: "category",
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
@@ -94,7 +94,7 @@ const options: ApexOptions = {
|
||||
yaxis: {
|
||||
title: {
|
||||
style: {
|
||||
fontSize: '0px',
|
||||
fontSize: "0px",
|
||||
},
|
||||
},
|
||||
min: 0,
|
||||
@@ -123,8 +123,8 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
if (!!weeklyStats && weeklyStats?.length > 0) {
|
||||
const datesArr = weeklyStats?.map((stat) => {
|
||||
// get day of week, month, and day of month
|
||||
const dateArr = stat.date.toString().split(' ');
|
||||
return dateArr.slice(0, 3).join(' ');
|
||||
const dateArr = stat.date.toString().split(" ");
|
||||
return dateArr.slice(0, 3).join(" ");
|
||||
});
|
||||
return datesArr;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
const [state, setState] = useState<ChartOneState>({
|
||||
series: [
|
||||
{
|
||||
name: 'Profit',
|
||||
name: "Profit",
|
||||
data: [4, 7, 10, 11, 13, 14, 17],
|
||||
},
|
||||
],
|
||||
@@ -144,7 +144,9 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
if (dailyRevenueArray && dailyRevenueArray.length > 0) {
|
||||
setState((prevState) => {
|
||||
// Check if a "Revenue" series already exists
|
||||
const existingSeriesIndex = prevState.series.findIndex((series) => series.name === 'Revenue');
|
||||
const existingSeriesIndex = prevState.series.findIndex(
|
||||
(series) => series.name === "Revenue",
|
||||
);
|
||||
|
||||
if (existingSeriesIndex >= 0) {
|
||||
// Update existing "Revenue" series data
|
||||
@@ -164,7 +166,7 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
series: [
|
||||
...prevState.series,
|
||||
{
|
||||
name: 'Revenue',
|
||||
name: "Revenue",
|
||||
data: dailyRevenueArray,
|
||||
},
|
||||
],
|
||||
@@ -198,37 +200,41 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
}, [daysOfWeekArr, dailyRevenueArray]);
|
||||
|
||||
return (
|
||||
<div className='col-span-12 rounded-sm border border-border bg-card px-5 pt-7.5 pb-5 shadow-default sm:px-7.5 xl:col-span-8'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap'>
|
||||
<div className='flex w-full flex-wrap gap-3 sm:gap-5'>
|
||||
<div className='flex min-w-47.5'>
|
||||
<span className='mt-1 mr-2 flex h-4 w-full max-w-4 items-center justify-center rounded-full border border-primary'>
|
||||
<span className='block h-2.5 w-full max-w-2.5 rounded-full bg-primary'></span>
|
||||
<div className="border-border bg-card pt-7.5 shadow-default sm:px-7.5 col-span-12 rounded-sm border px-5 pb-5 xl:col-span-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap">
|
||||
<div className="flex w-full flex-wrap gap-3 sm:gap-5">
|
||||
<div className="min-w-47.5 flex">
|
||||
<span className="border-primary mr-2 mt-1 flex h-4 w-full max-w-4 items-center justify-center rounded-full border">
|
||||
<span className="bg-primary block h-2.5 w-full max-w-2.5 rounded-full"></span>
|
||||
</span>
|
||||
<div className='w-full'>
|
||||
<p className='font-semibold text-primary'>Total Profit</p>
|
||||
<p className='text-sm font-medium text-muted-foreground'>Last 7 Days</p>
|
||||
<div className="w-full">
|
||||
<p className="text-primary font-semibold">Total Profit</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Last 7 Days
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-w-47.5'>
|
||||
<span className='mt-1 mr-2 flex h-4 w-full max-w-4 items-center justify-center rounded-full border border-secondary'>
|
||||
<span className='block h-2.5 w-full max-w-2.5 rounded-full bg-secondary'></span>
|
||||
<div className="min-w-47.5 flex">
|
||||
<span className="border-secondary mr-2 mt-1 flex h-4 w-full max-w-4 items-center justify-center rounded-full border">
|
||||
<span className="bg-secondary block h-2.5 w-full max-w-2.5 rounded-full"></span>
|
||||
</span>
|
||||
<div className='w-full'>
|
||||
<p className='font-semibold text-secondary'>Total Revenue</p>
|
||||
<p className='text-sm font-medium text-muted-foreground'>Last 7 Days</p>
|
||||
<div className="w-full">
|
||||
<p className="text-secondary font-semibold">Total Revenue</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Last 7 Days
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex w-full max-w-45 justify-end'>
|
||||
<div className='inline-flex items-center rounded-md bg-muted p-1.5'>
|
||||
<button className='rounded bg-background py-1 px-3 text-xs font-medium text-foreground shadow-card hover:bg-background hover:shadow-card'>
|
||||
<div className="max-w-45 flex w-full justify-end">
|
||||
<div className="bg-muted inline-flex items-center rounded-md p-1.5">
|
||||
<button className="bg-background text-foreground shadow-card hover:bg-background hover:shadow-card rounded px-3 py-1 text-xs font-medium">
|
||||
Day
|
||||
</button>
|
||||
<button className='rounded py-1 px-3 text-xs font-medium text-muted-foreground hover:bg-background hover:shadow-card'>
|
||||
<button className="text-muted-foreground hover:bg-background hover:shadow-card rounded px-3 py-1 text-xs font-medium">
|
||||
Week
|
||||
</button>
|
||||
<button className='rounded py-1 px-3 text-xs font-medium text-muted-foreground hover:bg-background hover:shadow-card'>
|
||||
<button className="text-muted-foreground hover:bg-background hover:shadow-card rounded px-3 py-1 text-xs font-medium">
|
||||
Month
|
||||
</button>
|
||||
</div>
|
||||
@@ -236,8 +242,13 @@ const RevenueAndProfitChart = ({ weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div id='chartOne' className='-ml-5'>
|
||||
<ReactApexChart options={chartOptions} series={state.series} type='area' height={350} />
|
||||
<div id="chartOne" className="-ml-5">
|
||||
<ReactApexChart
|
||||
options={chartOptions}
|
||||
series={state.series}
|
||||
type="area"
|
||||
height={350}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,42 +1,54 @@
|
||||
import { type PageViewSource } from 'wasp/entities';
|
||||
import { type PageViewSource } from "wasp/entities";
|
||||
|
||||
const SourcesTable = ({ sources }: { sources: PageViewSource[] | undefined }) => {
|
||||
const SourcesTable = ({
|
||||
sources,
|
||||
}: {
|
||||
sources: PageViewSource[] | undefined;
|
||||
}) => {
|
||||
return (
|
||||
<div className='rounded-sm border border-border bg-card px-5 pt-6 pb-2.5 shadow-default sm:px-7.5 xl:pb-1'>
|
||||
<h4 className='mb-6 text-xl font-semibold text-foreground'>Top Sources</h4>
|
||||
<div className="border-border bg-card shadow-default sm:px-7.5 rounded-sm border px-5 pb-2.5 pt-6 xl:pb-1">
|
||||
<h4 className="text-foreground mb-6 text-xl font-semibold">
|
||||
Top Sources
|
||||
</h4>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<div className='grid grid-cols-3 rounded-sm bg-gray-2 '>
|
||||
<div className='p-2.5 xl:p-5'>
|
||||
<h5 className='text-sm font-medium uppercase xsm:text-base'>Source</h5>
|
||||
<div className="flex flex-col">
|
||||
<div className="bg-gray-2 grid grid-cols-3 rounded-sm">
|
||||
<div className="p-2.5 xl:p-5">
|
||||
<h5 className="xsm:text-base text-sm font-medium uppercase">
|
||||
Source
|
||||
</h5>
|
||||
</div>
|
||||
<div className='p-2.5 text-center xl:p-5'>
|
||||
<h5 className='text-sm font-medium uppercase xsm:text-base'>Visitors</h5>
|
||||
<div className="p-2.5 text-center xl:p-5">
|
||||
<h5 className="xsm:text-base text-sm font-medium uppercase">
|
||||
Visitors
|
||||
</h5>
|
||||
</div>
|
||||
<div className='hidden p-2.5 text-center sm:block xl:p-5'>
|
||||
<h5 className='text-sm font-medium uppercase xsm:text-base'>Sales</h5>
|
||||
<div className="hidden p-2.5 text-center sm:block xl:p-5">
|
||||
<h5 className="xsm:text-base text-sm font-medium uppercase">
|
||||
Sales
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sources && sources.length > 0 ? (
|
||||
sources.map((source) => (
|
||||
<div className='grid grid-cols-3 border-b border-border'>
|
||||
<div className='flex items-center gap-3 p-2.5 xl:p-5'>
|
||||
<p className='text-foreground'>{source.name}</p>
|
||||
<div className="border-border grid grid-cols-3 border-b">
|
||||
<div className="flex items-center gap-3 p-2.5 xl:p-5">
|
||||
<p className="text-foreground">{source.name}</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-center p-2.5 xl:p-5'>
|
||||
<p className='text-foreground'>{source.visitors}</p>
|
||||
<div className="flex items-center justify-center p-2.5 xl:p-5">
|
||||
<p className="text-foreground">{source.visitors}</p>
|
||||
</div>
|
||||
|
||||
<div className='hidden items-center justify-center p-2.5 sm:flex xl:p-5'>
|
||||
<p className='text-foreground'>--</p>
|
||||
<div className="hidden items-center justify-center p-2.5 sm:flex xl:p-5">
|
||||
<p className="text-foreground">--</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='flex items-center justify-center p-2.5 xl:p-5'>
|
||||
<p className='text-foreground'>No data to display</p>
|
||||
<div className="flex items-center justify-center p-2.5 xl:p-5">
|
||||
<p className="text-foreground">No data to display</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,42 +1,57 @@
|
||||
import { ArrowDown, ArrowUp, Eye } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { ArrowDown, ArrowUp, Eye } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
type PageViewsStats = {
|
||||
totalPageViews: number | undefined;
|
||||
prevDayViewsChangePercent: string | undefined;
|
||||
};
|
||||
|
||||
const TotalPageViewsCard = ({ totalPageViews, prevDayViewsChangePercent }: PageViewsStats) => {
|
||||
const prevDayViewsChangePercentValue = parseInt(prevDayViewsChangePercent || '');
|
||||
const TotalPageViewsCard = ({
|
||||
totalPageViews,
|
||||
prevDayViewsChangePercent,
|
||||
}: PageViewsStats) => {
|
||||
const prevDayViewsChangePercentValue = parseInt(
|
||||
prevDayViewsChangePercent || "",
|
||||
);
|
||||
const isDeltaPositive = prevDayViewsChangePercentValue > 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
|
||||
<Eye className='size-6' />
|
||||
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
|
||||
<Eye className="size-6" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='flex justify-between'>
|
||||
<CardContent className="flex justify-between">
|
||||
<div>
|
||||
<h4 className='text-title-md font-bold text-foreground'>{totalPageViews}</h4>
|
||||
<span className='text-sm font-medium text-muted-foreground'>Total page views</span>
|
||||
<h4 className="text-title-md text-foreground font-bold">
|
||||
{totalPageViews}
|
||||
</h4>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Total page views
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
||||
'text-success':
|
||||
isDeltaPositive && prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0,
|
||||
'text-destructive':
|
||||
!isDeltaPositive && prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0,
|
||||
'text-muted-foreground': !prevDayViewsChangePercent || prevDayViewsChangePercentValue === 0,
|
||||
className={cn("flex items-center gap-1 text-sm font-medium", {
|
||||
"text-success":
|
||||
isDeltaPositive &&
|
||||
prevDayViewsChangePercent &&
|
||||
prevDayViewsChangePercentValue !== 0,
|
||||
"text-destructive":
|
||||
!isDeltaPositive &&
|
||||
prevDayViewsChangePercent &&
|
||||
prevDayViewsChangePercentValue !== 0,
|
||||
"text-muted-foreground":
|
||||
!prevDayViewsChangePercent ||
|
||||
prevDayViewsChangePercentValue === 0,
|
||||
})}
|
||||
>
|
||||
{prevDayViewsChangePercent && prevDayViewsChangePercentValue !== 0
|
||||
? `${prevDayViewsChangePercent}%`
|
||||
: '-'}
|
||||
: "-"}
|
||||
{prevDayViewsChangePercent &&
|
||||
prevDayViewsChangePercentValue !== 0 &&
|
||||
(isDeltaPositive ? <ArrowUp /> : <ArrowDown />)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ArrowDown, ArrowUp, ShoppingBag } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { ArrowDown, ArrowUp, ShoppingBag } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { type DailyStatsProps } from "../../../analytics/stats";
|
||||
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
||||
const isDeltaPositive = useMemo(() => {
|
||||
@@ -12,25 +12,30 @@ const TotalPayingUsersCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
|
||||
<ShoppingBag className='size-6' />
|
||||
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
|
||||
<ShoppingBag className="size-6" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='flex justify-between'>
|
||||
<CardContent className="flex justify-between">
|
||||
<div>
|
||||
<h4 className='text-title-md font-bold text-foreground'>{dailyStats?.paidUserCount}</h4>
|
||||
<span className='text-sm font-medium text-muted-foreground'>Total Paying Users</span>
|
||||
<h4 className="text-title-md text-foreground font-bold">
|
||||
{dailyStats?.paidUserCount}
|
||||
</h4>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Total Paying Users
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
||||
'text-success': isDeltaPositive && !isLoading,
|
||||
'text-destructive': !isDeltaPositive && !isLoading && dailyStats?.paidUserDelta !== 0,
|
||||
'text-muted-foreground': isLoading || !dailyStats?.paidUserDelta,
|
||||
className={cn("flex items-center gap-1 text-sm font-medium", {
|
||||
"text-success": isDeltaPositive && !isLoading,
|
||||
"text-destructive":
|
||||
!isDeltaPositive && !isLoading && dailyStats?.paidUserDelta !== 0,
|
||||
"text-muted-foreground": isLoading || !dailyStats?.paidUserDelta,
|
||||
})}
|
||||
>
|
||||
{isLoading ? '...' : dailyStats?.paidUserDelta ?? '-'}
|
||||
{isLoading ? "..." : (dailyStats?.paidUserDelta ?? "-")}
|
||||
{!isLoading &&
|
||||
(dailyStats?.paidUserDelta ?? 0) !== 0 &&
|
||||
(isDeltaPositive ? <ArrowUp /> : <ArrowDown />)}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { ArrowDown, ArrowUp, ShoppingCart } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { ArrowDown, ArrowUp, ShoppingCart } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { type DailyStatsProps } from "../../../analytics/stats";
|
||||
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
const TotalRevenueCard = ({ dailyStats, weeklyStats, isLoading }: DailyStatsProps) => {
|
||||
const TotalRevenueCard = ({
|
||||
dailyStats,
|
||||
weeklyStats,
|
||||
isLoading,
|
||||
}: DailyStatsProps) => {
|
||||
const isDeltaPositive = useMemo(() => {
|
||||
if (!weeklyStats) return false;
|
||||
return weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue > 0;
|
||||
@@ -12,37 +16,54 @@ const TotalRevenueCard = ({ dailyStats, weeklyStats, isLoading }: DailyStatsProp
|
||||
|
||||
const deltaPercentage = useMemo(() => {
|
||||
if (!weeklyStats || weeklyStats.length < 2 || isLoading) return;
|
||||
if (weeklyStats[1]?.totalRevenue === 0 || weeklyStats[0]?.totalRevenue === 0) return 0;
|
||||
if (
|
||||
weeklyStats[1]?.totalRevenue === 0 ||
|
||||
weeklyStats[0]?.totalRevenue === 0
|
||||
)
|
||||
return 0;
|
||||
|
||||
weeklyStats.sort((a, b) => b.id - a.id);
|
||||
|
||||
const percentage =
|
||||
((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) / weeklyStats[1]?.totalRevenue) * 100;
|
||||
((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) /
|
||||
weeklyStats[1]?.totalRevenue) *
|
||||
100;
|
||||
return Math.floor(percentage);
|
||||
}, [weeklyStats]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
|
||||
<ShoppingCart className='size-6' />
|
||||
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
|
||||
<ShoppingCart className="size-6" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='flex justify-between'>
|
||||
<CardContent className="flex justify-between">
|
||||
<div>
|
||||
<h4 className='text-title-md font-bold text-foreground'>${dailyStats?.totalRevenue}</h4>
|
||||
<span className='text-sm font-medium text-muted-foreground'>Total Revenue</span>
|
||||
<h4 className="text-title-md text-foreground font-bold">
|
||||
${dailyStats?.totalRevenue}
|
||||
</h4>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Total Revenue
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
||||
'text-success': isDeltaPositive && !isLoading && deltaPercentage !== 0,
|
||||
'text-destructive': !isDeltaPositive && !isLoading && deltaPercentage !== 0,
|
||||
'text-muted-foreground': isLoading || !deltaPercentage || deltaPercentage === 0,
|
||||
className={cn("flex items-center gap-1 text-sm font-medium", {
|
||||
"text-success":
|
||||
isDeltaPositive && !isLoading && deltaPercentage !== 0,
|
||||
"text-destructive":
|
||||
!isDeltaPositive && !isLoading && deltaPercentage !== 0,
|
||||
"text-muted-foreground":
|
||||
isLoading || !deltaPercentage || deltaPercentage === 0,
|
||||
})}
|
||||
>
|
||||
{isLoading ? '...' : deltaPercentage && deltaPercentage !== 0 ? `${deltaPercentage}%` : '-'}
|
||||
{isLoading
|
||||
? "..."
|
||||
: deltaPercentage && deltaPercentage !== 0
|
||||
? `${deltaPercentage}%`
|
||||
: "-"}
|
||||
{!isLoading &&
|
||||
deltaPercentage &&
|
||||
deltaPercentage !== 0 &&
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ArrowUp, UsersRound } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { type DailyStatsProps } from '../../../analytics/stats';
|
||||
import { Card, CardContent, CardHeader } from '../../../components/ui/card';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { ArrowUp, UsersRound } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { type DailyStatsProps } from "../../../analytics/stats";
|
||||
import { Card, CardContent, CardHeader } from "../../../components/ui/card";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
||||
const isDeltaPositive = useMemo(() => {
|
||||
@@ -12,25 +12,30 @@ const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className='flex h-11.5 w-11.5 items-center justify-center rounded-full bg-muted'>
|
||||
<UsersRound className='size-6' />
|
||||
<div className="h-11.5 w-11.5 bg-muted flex items-center justify-center rounded-full">
|
||||
<UsersRound className="size-6" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='flex justify-between'>
|
||||
<CardContent className="flex justify-between">
|
||||
<div>
|
||||
<h4 className='text-title-md font-bold text-foreground'>{dailyStats?.userCount}</h4>
|
||||
<span className='text-sm font-medium text-muted-foreground'>Total Signups</span>
|
||||
<h4 className="text-title-md text-foreground font-bold">
|
||||
{dailyStats?.userCount}
|
||||
</h4>
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Total Signups
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-sm font-medium', {
|
||||
'text-success': isDeltaPositive && !isLoading,
|
||||
'text-destructive': !isDeltaPositive && !isLoading && dailyStats?.userDelta !== 0,
|
||||
'text-muted-foreground': isLoading || !dailyStats?.userDelta,
|
||||
className={cn("flex items-center gap-1 text-sm font-medium", {
|
||||
"text-success": isDeltaPositive && !isLoading,
|
||||
"text-destructive":
|
||||
!isDeltaPositive && !isLoading && dailyStats?.userDelta !== 0,
|
||||
"text-muted-foreground": isLoading || !dailyStats?.userDelta,
|
||||
})}
|
||||
>
|
||||
{isLoading ? '...' : dailyStats?.userDelta ?? '-'}
|
||||
{isLoading ? "..." : (dailyStats?.userDelta ?? "-")}
|
||||
{!isLoading && (dailyStats?.userDelta ?? 0) > 0 && <ArrowUp />}
|
||||
</span>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { MessageCircleMore } from 'lucide-react';
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { MessageCircleMore } from "lucide-react";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
|
||||
const MessageButton = () => {
|
||||
return (
|
||||
<li className='relative' x-data='{ dropdownOpen: false, notifying: true }'>
|
||||
<li className="relative" x-data="{ dropdownOpen: false, notifying: true }">
|
||||
<WaspRouterLink
|
||||
className='relative flex h-8.5 w-8.5 items-center justify-center rounded-full border-[0.5px] border-stroke bg-gray hover:text-primary dark:border-strokedark dark:bg-meta-4 dark:text-white'
|
||||
className="h-8.5 w-8.5 border-stroke bg-gray hover:text-primary dark:border-strokedark dark:bg-meta-4 relative flex items-center justify-center rounded-full border-[0.5px] dark:text-white"
|
||||
to={routes.AdminMessagesRoute.to}
|
||||
>
|
||||
<span className='absolute -top-0.5 -right-0.5 z-1 h-2 w-2 rounded-full bg-meta-1'>
|
||||
<span className="z-1 bg-meta-1 absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full">
|
||||
{/* TODO: only animate if there are new messages */}
|
||||
<span className='absolute -z-1 inline-flex h-full w-full animate-ping rounded-full bg-meta-1 opacity-75'></span>
|
||||
<span className="-z-1 bg-meta-1 absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
||||
</span>
|
||||
<MessageCircleMore className='size-5' />
|
||||
<MessageCircleMore className="size-5" />
|
||||
</WaspRouterLink>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// TODO: Add messages page
|
||||
import type { AuthUser } from "wasp/auth"
|
||||
import DefaultLayout from "../../layout/DefaultLayout"
|
||||
import type { AuthUser } from "wasp/auth";
|
||||
import DefaultLayout from "../../layout/DefaultLayout";
|
||||
|
||||
function AdminMessages({user} : {user: AuthUser}) {
|
||||
function AdminMessages({ user }: { user: AuthUser }) {
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<div>This page is under construction 🚧</div>
|
||||
</DefaultLayout>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminMessages
|
||||
export default AdminMessages;
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { Ellipsis, SquarePen, Trash2 } from 'lucide-react';
|
||||
import { Ellipsis, SquarePen, Trash2 } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../../../components/ui/dropdown-menu';
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
|
||||
const DropdownEditDelete = () => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button>
|
||||
<Ellipsis className='size-4' />
|
||||
<Ellipsis className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-40'>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem>
|
||||
<SquarePen className='size-4 mr-2' />
|
||||
<SquarePen className="mr-2 size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Trash2 className='size-4 mr-2' />
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import UsersTable from './UsersTable';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import Breadcrumb from "../../layout/Breadcrumb";
|
||||
import DefaultLayout from "../../layout/DefaultLayout";
|
||||
import UsersTable from "./UsersTable";
|
||||
|
||||
const Users = ({ user }: { user: AuthUser }) => {
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<Breadcrumb pageName='Users' />
|
||||
<div className='flex flex-col gap-10'>
|
||||
<Breadcrumb pageName="Users" />
|
||||
<div className="flex flex-col gap-10">
|
||||
<UsersTable />
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from 'wasp/client/auth';
|
||||
import { getPaginatedUsers, updateIsUserAdminById, useQuery } from 'wasp/client/operations';
|
||||
import { type User } from 'wasp/entities';
|
||||
import useDebounce from '../../../client/hooks/useDebounce';
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import { Checkbox } from '../../../components/ui/checkbox';
|
||||
import { Input } from '../../../components/ui/input';
|
||||
import { Label } from '../../../components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../components/ui/select';
|
||||
import { Switch } from '../../../components/ui/switch';
|
||||
import { SubscriptionStatus } from '../../../payment/plans';
|
||||
import LoadingSpinner from '../../layout/LoadingSpinner';
|
||||
import DropdownEditDelete from './DropdownEditDelete';
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "wasp/client/auth";
|
||||
import {
|
||||
getPaginatedUsers,
|
||||
updateIsUserAdminById,
|
||||
useQuery,
|
||||
} from "wasp/client/operations";
|
||||
import { type User } from "wasp/entities";
|
||||
import useDebounce from "../../../client/hooks/useDebounce";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Checkbox } from "../../../components/ui/checkbox";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../../components/ui/select";
|
||||
import { Switch } from "../../../components/ui/switch";
|
||||
import { SubscriptionStatus } from "../../../payment/plans";
|
||||
import LoadingSpinner from "../../layout/LoadingSpinner";
|
||||
import DropdownEditDelete from "./DropdownEditDelete";
|
||||
|
||||
function AdminSwitch({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) {
|
||||
function AdminSwitch({ id, isAdmin }: Pick<User, "id" | "isAdmin">) {
|
||||
const { data: currentUser } = useAuth();
|
||||
const isCurrentUser = currentUser?.id === id;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={isAdmin}
|
||||
onCheckedChange={(value) => updateIsUserAdminById({ id: id, isAdmin: value })}
|
||||
onCheckedChange={(value) =>
|
||||
updateIsUserAdminById({ id: id, isAdmin: value })
|
||||
}
|
||||
disabled={isCurrentUser}
|
||||
/>
|
||||
);
|
||||
@@ -30,10 +42,12 @@ function AdminSwitch({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) {
|
||||
const UsersTable = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [emailFilter, setEmailFilter] = useState<string | undefined>(undefined);
|
||||
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
|
||||
const [subscriptionStatusFilter, setSubscriptionStatusFilter] = useState<Array<SubscriptionStatus | null>>(
|
||||
[]
|
||||
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [subscriptionStatusFilter, setSubscriptionStatusFilter] = useState<
|
||||
Array<SubscriptionStatus | null>
|
||||
>([]);
|
||||
|
||||
const debouncedEmailFilter = useDebounce(emailFilter, 300);
|
||||
|
||||
@@ -44,7 +58,9 @@ const UsersTable = () => {
|
||||
filter: {
|
||||
...(debouncedEmailFilter && { emailContains: debouncedEmailFilter }),
|
||||
...(isAdminFilter !== undefined && { isAdmin: isAdminFilter }),
|
||||
...(subscriptionStatusFilter.length > 0 && { subscriptionStatusIn: subscriptionStatusFilter }),
|
||||
...(subscriptionStatusFilter.length > 0 && {
|
||||
subscriptionStatusIn: subscriptionStatusFilter,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,7 +68,7 @@ const UsersTable = () => {
|
||||
function backToPageOne() {
|
||||
setCurrentPage(1);
|
||||
},
|
||||
[debouncedEmailFilter, subscriptionStatusFilter, isAdminFilter]
|
||||
[debouncedEmailFilter, subscriptionStatusFilter, isAdminFilter],
|
||||
);
|
||||
|
||||
const handleStatusToggle = (status: SubscriptionStatus | null) => {
|
||||
@@ -69,85 +85,99 @@ const UsersTable = () => {
|
||||
setSubscriptionStatusFilter([]);
|
||||
};
|
||||
|
||||
const hasActiveFilters = subscriptionStatusFilter && subscriptionStatusFilter.length > 0;
|
||||
const hasActiveFilters =
|
||||
subscriptionStatusFilter && subscriptionStatusFilter.length > 0;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='rounded-sm border border-border bg-card shadow'>
|
||||
<div className='flex-col flex items-start justify-between p-6 gap-3 w-full bg-muted/40'>
|
||||
<span className='text-sm font-medium'>Filters:</span>
|
||||
<div className='flex items-center justify-between gap-3 w-full px-2'>
|
||||
<div className='relative flex items-center gap-3 '>
|
||||
<Label htmlFor='email-filter' className='text-sm text-muted-foreground'>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="border-border bg-card rounded-sm border shadow">
|
||||
<div className="bg-muted/40 flex w-full flex-col items-start justify-between gap-3 p-6">
|
||||
<span className="text-sm font-medium">Filters:</span>
|
||||
<div className="flex w-full items-center justify-between gap-3 px-2">
|
||||
<div className="relative flex items-center gap-3">
|
||||
<Label
|
||||
htmlFor="email-filter"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
email:
|
||||
</Label>
|
||||
<Input
|
||||
type='text'
|
||||
id='email-filter'
|
||||
placeholder='dude@example.com'
|
||||
type="text"
|
||||
id="email-filter"
|
||||
placeholder="dude@example.com"
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setEmailFilter(value === '' ? undefined : value);
|
||||
setEmailFilter(value === "" ? undefined : value);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor='status-filter' className='text-sm ml-2 text-muted-foreground'>
|
||||
<Label
|
||||
htmlFor="status-filter"
|
||||
className="text-muted-foreground ml-2 text-sm"
|
||||
>
|
||||
status:
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<div className="relative">
|
||||
<Select>
|
||||
<SelectTrigger className='w-full min-w-[200px]'>
|
||||
<SelectValue placeholder='Select Status Filter' />
|
||||
<SelectTrigger className="w-full min-w-[200px]">
|
||||
<SelectValue placeholder="Select Status Filter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className='w-[300px]'>
|
||||
<div className='p-2'>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<span className='text-sm font-medium'>Subscription Status</span>
|
||||
<SelectContent className="w-[300px]">
|
||||
<div className="p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
Subscription Status
|
||||
</span>
|
||||
{subscriptionStatusFilter.length > 0 && (
|
||||
<button
|
||||
onClick={clearAllStatusFilters}
|
||||
className='text-xs text-muted-foreground hover:text-foreground'
|
||||
className="text-muted-foreground hover:text-foreground text-xs"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id='all-statuses'
|
||||
id="all-statuses"
|
||||
checked={subscriptionStatusFilter.length === 0}
|
||||
onCheckedChange={() => clearAllStatusFilters()}
|
||||
/>
|
||||
<Label
|
||||
htmlFor='all-statuses'
|
||||
className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
htmlFor="all-statuses"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
All Statuses
|
||||
</Label>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id='has-not-subscribed'
|
||||
id="has-not-subscribed"
|
||||
checked={subscriptionStatusFilter.includes(null)}
|
||||
onCheckedChange={() => handleStatusToggle(null)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor='has-not-subscribed'
|
||||
className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
htmlFor="has-not-subscribed"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Has Not Subscribed
|
||||
</Label>
|
||||
</div>
|
||||
{Object.values(SubscriptionStatus).map((status) => (
|
||||
<div key={status} className='flex items-center space-x-2'>
|
||||
<div
|
||||
key={status}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={status}
|
||||
checked={subscriptionStatusFilter.includes(status)}
|
||||
checked={subscriptionStatusFilter.includes(
|
||||
status,
|
||||
)}
|
||||
onCheckedChange={() => handleStatusToggle(status)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={status}
|
||||
className='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{status}
|
||||
</Label>
|
||||
@@ -158,63 +188,75 @@ const UsersTable = () => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='admin-filter' className='text-sm ml-2 text-muted-foreground'>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor="admin-filter"
|
||||
className="text-muted-foreground ml-2 text-sm"
|
||||
>
|
||||
isAdmin:
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value === 'both') {
|
||||
if (value === "both") {
|
||||
setIsAdminFilter(undefined);
|
||||
} else {
|
||||
setIsAdminFilter(value === 'true');
|
||||
setIsAdminFilter(value === "true");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='w-full'>
|
||||
<SelectValue placeholder='both' />
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="both" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='both'>both</SelectItem>
|
||||
<SelectItem value='true'>true</SelectItem>
|
||||
<SelectItem value='false'>false</SelectItem>
|
||||
<SelectItem value="both">both</SelectItem>
|
||||
<SelectItem value="true">true</SelectItem>
|
||||
<SelectItem value="false">false</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{data?.totalPages && (
|
||||
<div className='max-w-60 flex flex-row items-center'>
|
||||
<span className='text-md mr-2 text-foreground'>page</span>
|
||||
<div className="flex max-w-60 flex-row items-center">
|
||||
<span className="text-md text-foreground mr-2">page</span>
|
||||
<Input
|
||||
type='number'
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={currentPage}
|
||||
max={data?.totalPages}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.currentTarget.value);
|
||||
if (data?.totalPages && value <= data?.totalPages && value > 0) {
|
||||
if (
|
||||
data?.totalPages &&
|
||||
value <= data?.totalPages &&
|
||||
value > 0
|
||||
) {
|
||||
setCurrentPage(value);
|
||||
}
|
||||
}}
|
||||
className='w-20'
|
||||
className="w-20"
|
||||
/>
|
||||
<span className='text-md text-foreground'> /{data?.totalPages} </span>
|
||||
<span className="text-md text-foreground">
|
||||
{" "}
|
||||
/{data?.totalPages}{" "}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<div className='flex items-center gap-2 px-2 pt-2 border-border'>
|
||||
<span className='text-sm font-medium text-muted-foreground'>Active Filters:</span>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<div className="border-border flex items-center gap-2 px-2 pt-2">
|
||||
<span className="text-muted-foreground text-sm font-medium">
|
||||
Active Filters:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscriptionStatusFilter.map((status) => (
|
||||
<Button
|
||||
key={status ?? 'null'}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
key={status ?? "null"}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStatusToggle(status)}
|
||||
>
|
||||
<X className='w-3 h-3 mr-1' />
|
||||
{status ?? 'Has Not Subscribed'}
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
{status ?? "Has Not Subscribed"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -222,46 +264,53 @@ const UsersTable = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-9 border-t-4 border-border py-4.5 px-4 md:px-6 '>
|
||||
<div className='col-span-3 flex items-center'>
|
||||
<p className='font-medium'>Email / Username</p>
|
||||
<div className="border-border py-4.5 grid grid-cols-9 border-t-4 px-4 md:px-6">
|
||||
<div className="col-span-3 flex items-center">
|
||||
<p className="font-medium">Email / Username</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='font-medium'>Subscription Status</p>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<p className="font-medium">Subscription Status</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='font-medium'>Stripe ID</p>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<p className="font-medium">Stripe ID</p>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<p className='font-medium'>Is Admin</p>
|
||||
<div className="col-span-1 flex items-center">
|
||||
<p className="font-medium">Is Admin</p>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<p className='font-medium'></p>
|
||||
<div className="col-span-1 flex items-center">
|
||||
<p className="font-medium"></p>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && <LoadingSpinner />}
|
||||
{!!data?.users &&
|
||||
data?.users?.length > 0 &&
|
||||
data.users.map((user) => (
|
||||
<div key={user.id} className='grid grid-cols-9 gap-4 py-4.5 px-4 md:px-6 '>
|
||||
<div className='col-span-3 flex items-center'>
|
||||
<div className='flex flex-col gap-1 '>
|
||||
<p className='text-sm text-foreground'>{user.email}</p>
|
||||
<p className='text-sm text-foreground'>{user.username}</p>
|
||||
<div
|
||||
key={user.id}
|
||||
className="py-4.5 grid grid-cols-9 gap-4 px-4 md:px-6"
|
||||
>
|
||||
<div className="col-span-3 flex items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-foreground text-sm">{user.email}</p>
|
||||
<p className="text-foreground text-sm">{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='text-sm text-foreground'>{user.subscriptionStatus}</p>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<p className="text-foreground text-sm">
|
||||
{user.subscriptionStatus}
|
||||
</p>
|
||||
</div>
|
||||
<div className='col-span-2 flex items-center'>
|
||||
<p className='text-sm text-muted-foreground'>{user.paymentProcessorUserId}</p>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{user.paymentProcessorUserId}
|
||||
</p>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<div className='text-sm text-foreground'>
|
||||
<div className="col-span-1 flex items-center">
|
||||
<div className="text-foreground text-sm">
|
||||
<AdminSwitch {...user} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='col-span-1 flex items-center'>
|
||||
<div className="col-span-1 flex items-center">
|
||||
<DropdownEditDelete />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,179 +1,191 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import Breadcrumb from "../../layout/Breadcrumb";
|
||||
import DefaultLayout from "../../layout/DefaultLayout";
|
||||
|
||||
const Calendar = ({ user }: { user: AuthUser }) => {
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<Breadcrumb pageName='Calendar' />
|
||||
<div className='w-full max-w-full rounded-sm border border-border bg-card shadow-default'>
|
||||
<table className='w-full'>
|
||||
<Breadcrumb pageName="Calendar" />
|
||||
<div className="border-border bg-card shadow-default w-full max-w-full rounded-sm border">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className='grid grid-cols-7 rounded-t-sm bg-primary text-primary-foreground'>
|
||||
<th className='flex h-15 items-center justify-center rounded-tl-sm p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Sunday </span>
|
||||
<span className='block lg:hidden'> Sun </span>
|
||||
<tr className="bg-primary text-primary-foreground grid grid-cols-7 rounded-t-sm">
|
||||
<th className="h-15 flex items-center justify-center rounded-tl-sm p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||
<span className="hidden lg:block"> Sunday </span>
|
||||
<span className="block lg:hidden"> Sun </span>
|
||||
</th>
|
||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Monday </span>
|
||||
<span className='block lg:hidden'> Mon </span>
|
||||
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||
<span className="hidden lg:block"> Monday </span>
|
||||
<span className="block lg:hidden"> Mon </span>
|
||||
</th>
|
||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Tuesday </span>
|
||||
<span className='block lg:hidden'> Tue </span>
|
||||
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||
<span className="hidden lg:block"> Tuesday </span>
|
||||
<span className="block lg:hidden"> Tue </span>
|
||||
</th>
|
||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Wednesday </span>
|
||||
<span className='block lg:hidden'> Wed </span>
|
||||
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||
<span className="hidden lg:block"> Wednesday </span>
|
||||
<span className="block lg:hidden"> Wed </span>
|
||||
</th>
|
||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Thursday </span>
|
||||
<span className='block lg:hidden'> Thur </span>
|
||||
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||
<span className="hidden lg:block"> Thursday </span>
|
||||
<span className="block lg:hidden"> Thur </span>
|
||||
</th>
|
||||
<th className='flex h-15 items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Friday </span>
|
||||
<span className='block lg:hidden'> Fri </span>
|
||||
<th className="h-15 flex items-center justify-center p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||
<span className="hidden lg:block"> Friday </span>
|
||||
<span className="block lg:hidden"> Fri </span>
|
||||
</th>
|
||||
<th className='flex h-15 items-center justify-center rounded-tr-sm p-1 text-xs font-semibold sm:text-base xl:p-5'>
|
||||
<span className='hidden lg:block'> Saturday </span>
|
||||
<span className='block lg:hidden'> Sat </span>
|
||||
<th className="h-15 flex items-center justify-center rounded-tr-sm p-1 text-xs font-semibold sm:text-base xl:p-5">
|
||||
<span className="hidden lg:block"> Saturday </span>
|
||||
<span className="block lg:hidden"> Sat </span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* <!-- Line 1 --> */}
|
||||
<tr className='grid grid-cols-7'>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>1</span>
|
||||
<div className='group h-16 w-full flex-grow cursor-pointer py-1 md:h-30'>
|
||||
<span className='group-hover:text-primary md:hidden'>More</span>
|
||||
<div className='event invisible absolute left-2 z-99 mb-1 flex w-[200%] flex-col rounded-sm border-l-[3px] border-primary bg-muted px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 md:visible md:w-[190%] md:opacity-100'>
|
||||
<span className='event-name text-sm font-semibold text-foreground'>Redesign Website</span>
|
||||
<span className='time text-sm font-medium text-foreground'>1 Dec - 2 Dec</span>
|
||||
<tr className="grid grid-cols-7">
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">1</span>
|
||||
<div className="md:h-30 group h-16 w-full flex-grow cursor-pointer py-1">
|
||||
<span className="group-hover:text-primary md:hidden">
|
||||
More
|
||||
</span>
|
||||
<div className="event z-99 border-primary bg-muted invisible absolute left-2 mb-1 flex w-[200%] flex-col rounded-sm border-l-[3px] px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 md:visible md:w-[190%] md:opacity-100">
|
||||
<span className="event-name text-foreground text-sm font-semibold">
|
||||
Redesign Website
|
||||
</span>
|
||||
<span className="time text-foreground text-sm font-medium">
|
||||
1 Dec - 2 Dec
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>2</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">2</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>3</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">3</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>4</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">4</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>5</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">5</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>6</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">6</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>7</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">7</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 1 --> */}
|
||||
{/* <!-- Line 2 --> */}
|
||||
<tr className='grid grid-cols-7'>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>8</span>
|
||||
<tr className="grid grid-cols-7">
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">8</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>9</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">9</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>10</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">10</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>11</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">11</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>12</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">12</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>13</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">13</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>14</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">14</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 2 --> */}
|
||||
<tr className='grid grid-cols-7'>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>15</span>
|
||||
<tr className="grid grid-cols-7">
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">15</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>16</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">16</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>17</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">17</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>18</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">18</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>19</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">19</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>20</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">20</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>21</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">21</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 3 --> */}
|
||||
<tr className='grid grid-cols-7'>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>22</span>
|
||||
<tr className="grid grid-cols-7">
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">22</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>23</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">23</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>24</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">24</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>25</span>
|
||||
<div className='group h-16 w-full flex-grow cursor-pointer py-1 md:h-30'>
|
||||
<span className='group-hover:text-primary md:hidden'>More</span>
|
||||
<div className='event invisible absolute left-2 z-99 mb-1 flex w-[300%] flex-col rounded-sm border-l-[3px] border-primary bg-muted px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 md:visible md:w-[290%] md:opacity-100'>
|
||||
<span className='event-name text-sm font-semibold text-foreground'>App Design</span>
|
||||
<span className='time text-sm font-medium text-foreground'>25 Dec - 27 Dec</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">25</span>
|
||||
<div className="md:h-30 group h-16 w-full flex-grow cursor-pointer py-1">
|
||||
<span className="group-hover:text-primary md:hidden">
|
||||
More
|
||||
</span>
|
||||
<div className="event z-99 border-primary bg-muted invisible absolute left-2 mb-1 flex w-[300%] flex-col rounded-sm border-l-[3px] px-3 py-1 text-left opacity-0 group-hover:visible group-hover:opacity-100 md:visible md:w-[290%] md:opacity-100">
|
||||
<span className="event-name text-foreground text-sm font-semibold">
|
||||
App Design
|
||||
</span>
|
||||
<span className="time text-foreground text-sm font-medium">
|
||||
25 Dec - 27 Dec
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>26</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">26</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>27</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">27</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>28</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">28</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 4 --> */}
|
||||
<tr className='grid grid-cols-7'>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>29</span>
|
||||
<tr className="grid grid-cols-7">
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">29</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>30</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">30</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>31</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">31</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>1</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">1</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>2</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">2</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>3</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">3</span>
|
||||
</td>
|
||||
<td className='ease relative h-20 cursor-pointer border border-border p-2 transition duration-500 text-accent hover:bg-accent hover:text-accent-foreground md:h-25 md:p-6 xl:h-31'>
|
||||
<span className='font-medium'>4</span>
|
||||
<td className="ease border-border text-accent hover:bg-accent hover:text-accent-foreground md:h-25 xl:h-31 relative h-20 cursor-pointer border p-2 transition duration-500 md:p-6">
|
||||
<span className="font-medium">4</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <!-- Line 5 --> */}
|
||||
|
||||
@@ -1,176 +1,202 @@
|
||||
import { FileText, Mail, Upload, User } from 'lucide-react';
|
||||
import { FormEvent } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../../components/ui/card';
|
||||
import { Input } from '../../../components/ui/input';
|
||||
import { Label } from '../../../components/ui/label';
|
||||
import { Textarea } from '../../../components/ui/textarea';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import { FileText, Mail, Upload, User } from "lucide-react";
|
||||
import { FormEvent } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import Breadcrumb from "../../layout/Breadcrumb";
|
||||
import DefaultLayout from "../../layout/DefaultLayout";
|
||||
|
||||
const SettingsPage = ({ user }: { user: AuthUser }) => {
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
// TODO add toast provider / wrapper
|
||||
event.preventDefault();
|
||||
const confirmed = confirm('Are you sure you want to save the changes?');
|
||||
const confirmed = confirm("Are you sure you want to save the changes?");
|
||||
if (confirmed) {
|
||||
toast.success('Your changes have been saved successfully!');
|
||||
toast.success("Your changes have been saved successfully!");
|
||||
} else {
|
||||
toast.error('Your changes have not been saved!');
|
||||
toast.error("Your changes have not been saved!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<div className='mx-auto max-w-270'>
|
||||
<Breadcrumb pageName='Settings' />
|
||||
<div className="max-w-270 mx-auto">
|
||||
<Breadcrumb pageName="Settings" />
|
||||
|
||||
<div className='grid grid-cols-5 gap-8'>
|
||||
<div className='col-span-5 xl:col-span-3'>
|
||||
<div className="grid grid-cols-5 gap-8">
|
||||
<div className="col-span-5 xl:col-span-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Personal Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className='mb-5.5 flex flex-col gap-5.5 sm:flex-row'>
|
||||
<div className='w-full sm:w-1/2'>
|
||||
<Label htmlFor='full-name' className='mb-3 block text-sm font-medium text-foreground'>
|
||||
<div className="mb-5.5 gap-5.5 flex flex-col sm:flex-row">
|
||||
<div className="w-full sm:w-1/2">
|
||||
<Label
|
||||
htmlFor="full-name"
|
||||
className="text-foreground mb-3 block text-sm font-medium"
|
||||
>
|
||||
Full Name
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<User className='absolute left-4.5 top-2 h-5 w-5 text-muted-foreground' />
|
||||
<div className="relative">
|
||||
<User className="left-4.5 text-muted-foreground absolute top-2 h-5 w-5" />
|
||||
<Input
|
||||
className='pl-11.5'
|
||||
type='text'
|
||||
name='fullName'
|
||||
id='full-name'
|
||||
placeholder='Devid Jhon'
|
||||
defaultValue='Devid Jhon'
|
||||
className="pl-11.5"
|
||||
type="text"
|
||||
name="fullName"
|
||||
id="full-name"
|
||||
placeholder="Devid Jhon"
|
||||
defaultValue="Devid Jhon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='w-full sm:w-1/2'>
|
||||
<div className="w-full sm:w-1/2">
|
||||
<Label
|
||||
htmlFor='phone-number'
|
||||
className='mb-3 block text-sm font-medium text-foreground'
|
||||
htmlFor="phone-number"
|
||||
className="text-foreground mb-3 block text-sm font-medium"
|
||||
>
|
||||
Phone Number
|
||||
</Label>
|
||||
<Input
|
||||
type=''
|
||||
name='phoneNumber'
|
||||
id='phone-number'
|
||||
placeholder='+990 3343 7865'
|
||||
defaultValue='+990 3343 7865'
|
||||
type=""
|
||||
name="phoneNumber"
|
||||
id="phone-number"
|
||||
placeholder="+990 3343 7865"
|
||||
defaultValue="+990 3343 7865"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-5.5'>
|
||||
<Label htmlFor='email-address' className='mb-3 block text-sm font-medium text-foreground'>
|
||||
<div className="mb-5.5">
|
||||
<Label
|
||||
htmlFor="email-address"
|
||||
className="text-foreground mb-3 block text-sm font-medium"
|
||||
>
|
||||
Email Address
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<Mail className='absolute left-4.5 top-2 h-5 w-5 text-muted-foreground' />
|
||||
<div className="relative">
|
||||
<Mail className="left-4.5 text-muted-foreground absolute top-2 h-5 w-5" />
|
||||
<Input
|
||||
className='pl-11.5'
|
||||
type='email'
|
||||
name='emailAddress'
|
||||
id='email-address'
|
||||
placeholder='devidjond45@gmail.com'
|
||||
defaultValue='devidjond45@gmail.com'
|
||||
className="pl-11.5"
|
||||
type="email"
|
||||
name="emailAddress"
|
||||
id="email-address"
|
||||
placeholder="devidjond45@gmail.com"
|
||||
defaultValue="devidjond45@gmail.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mb-5.5'>
|
||||
<Label htmlFor='username' className='mb-3 block text-sm font-medium text-foreground'>
|
||||
<div className="mb-5.5">
|
||||
<Label
|
||||
htmlFor="username"
|
||||
className="text-foreground mb-3 block text-sm font-medium"
|
||||
>
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
type='text'
|
||||
name='Username'
|
||||
id='username'
|
||||
placeholder='devidjhon24'
|
||||
defaultValue='devidjhon24'
|
||||
type="text"
|
||||
name="Username"
|
||||
id="username"
|
||||
placeholder="devidjhon24"
|
||||
defaultValue="devidjhon24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mb-5.5'>
|
||||
<Label htmlFor='bio' className='mb-3 block text-sm font-medium text-foreground'>
|
||||
<div className="mb-5.5">
|
||||
<Label
|
||||
htmlFor="bio"
|
||||
className="text-foreground mb-3 block text-sm font-medium"
|
||||
>
|
||||
BIO
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<FileText className='absolute left-4.5 top-4 h-5 w-5 text-muted-foreground' />
|
||||
<div className="relative">
|
||||
<FileText className="left-4.5 text-muted-foreground absolute top-4 h-5 w-5" />
|
||||
<Textarea
|
||||
className='w-full rounded border border-border bg-background py-3 pl-11.5 pr-4.5 text-foreground focus:border-primary focus-visible:outline-none'
|
||||
name='bio'
|
||||
id='bio'
|
||||
className="border-border bg-background pl-11.5 pr-4.5 text-foreground focus:border-primary w-full rounded border py-3 focus-visible:outline-none"
|
||||
name="bio"
|
||||
id="bio"
|
||||
rows={6}
|
||||
placeholder='Write your bio here'
|
||||
defaultValue='Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque posuere fermentum urna, eu condimentum mauris tempus ut. Donec fermentum blandit aliquet.'
|
||||
placeholder="Write your bio here"
|
||||
defaultValue="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque posuere fermentum urna, eu condimentum mauris tempus ut. Donec fermentum blandit aliquet."
|
||||
></Textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-4.5'>
|
||||
<Button variant='outline' type='submit'>
|
||||
<div className="gap-4.5 flex justify-end">
|
||||
<Button variant="outline" type="submit">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit'>Save</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className='col-span-5 xl:col-span-2'>
|
||||
<div className="col-span-5 xl:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Your Photo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action='#'>
|
||||
<div className='mb-4 flex items-center gap-3'>
|
||||
<div className='h-14 w-14 rounded-full'>{/* <img src={userThree} alt="User" /> */}</div>
|
||||
<form action="#">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="h-14 w-14 rounded-full">
|
||||
{/* <img src={userThree} alt="User" /> */}
|
||||
</div>
|
||||
<div>
|
||||
<span className='mb-1.5 text-foreground'>Edit your photo</span>
|
||||
<span className='flex gap-2.5'>
|
||||
<button className='text-sm hover:text-primary'>Delete</button>
|
||||
<button className='text-sm hover:text-primary'>Update</button>
|
||||
<span className="text-foreground mb-1.5">
|
||||
Edit your photo
|
||||
</span>
|
||||
<span className="flex gap-2.5">
|
||||
<button className="hover:text-primary text-sm">
|
||||
Delete
|
||||
</button>
|
||||
<button className="hover:text-primary text-sm">
|
||||
Update
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id='FileUpload'
|
||||
className='relative mb-5.5 block w-full cursor-pointer appearance-none rounded border-2 border-dashed border-primary bg-background py-4 px-4 sm:py-7.5'
|
||||
id="FileUpload"
|
||||
className="mb-5.5 border-primary bg-background sm:py-7.5 relative block w-full cursor-pointer appearance-none rounded border-2 border-dashed px-4 py-4"
|
||||
>
|
||||
<input
|
||||
type='file'
|
||||
accept='image/*'
|
||||
className='absolute inset-0 z-50 m-0 h-full w-full cursor-pointer p-0 opacity-0 outline-none'
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="absolute inset-0 z-50 m-0 h-full w-full cursor-pointer p-0 opacity-0 outline-none"
|
||||
/>
|
||||
<div className='flex flex-col items-center justify-center space-y-3'>
|
||||
<span className='flex h-10 w-10 items-center justify-center rounded-full border border-border bg-background'>
|
||||
<Upload className='h-4 w-4 text-primary' />
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<span className="border-border bg-background flex h-10 w-10 items-center justify-center rounded-full border">
|
||||
<Upload className="text-primary h-4 w-4" />
|
||||
</span>
|
||||
<p>
|
||||
<span className='text-primary'>Click to upload</span> or drag and drop
|
||||
<span className="text-primary">Click to upload</span> or
|
||||
drag and drop
|
||||
</p>
|
||||
<p className='mt-1.5'>SVG, PNG, JPG or GIF</p>
|
||||
<p className="mt-1.5">SVG, PNG, JPG or GIF</p>
|
||||
<p>(max, 800 X 800px)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-4.5'>
|
||||
<Button variant='outline' type='submit'>
|
||||
<div className="gap-4.5 flex justify-end">
|
||||
<Button variant="outline" type="submit">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit'>Save</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import { Heart, Plus, Trash2 } from 'lucide-react';
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import Breadcrumb from '../../layout/Breadcrumb';
|
||||
import DefaultLayout from '../../layout/DefaultLayout';
|
||||
import { Heart, Plus, Trash2 } from "lucide-react";
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import Breadcrumb from "../../layout/Breadcrumb";
|
||||
import DefaultLayout from "../../layout/DefaultLayout";
|
||||
|
||||
const Buttons = ({ user }: { user: AuthUser }) => {
|
||||
return (
|
||||
<DefaultLayout user={user}>
|
||||
<Breadcrumb pageName='Buttons' />
|
||||
<Breadcrumb pageName="Buttons" />
|
||||
|
||||
{/* Button Variants */}
|
||||
<div className='mb-10 rounded-sm border border-border bg-card shadow-default'>
|
||||
<div className='border-b border-border px-7 py-4'>
|
||||
<h3 className='font-medium text-foreground'>Button Variants</h3>
|
||||
<div className="border-border bg-card shadow-default mb-10 rounded-sm border">
|
||||
<div className="border-border border-b px-7 py-4">
|
||||
<h3 className="text-foreground font-medium">Button Variants</h3>
|
||||
</div>
|
||||
|
||||
<div className='p-4 md:p-6 xl:p-9'>
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
<Button variant='default'>Default</Button>
|
||||
<Button variant='outline'>Outline</Button>
|
||||
<Button variant='secondary'>Secondary</Button>
|
||||
<Button variant='ghost'>Ghost</Button>
|
||||
<Button variant='link'>Link</Button>
|
||||
<Button variant='destructive'>Destructive</Button>
|
||||
<div className="p-4 md:p-6 xl:p-9">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button variant="default">Default</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="link">Link</Button>
|
||||
<Button variant="destructive">Destructive</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Button Sizes */}
|
||||
<div className='mb-10 rounded-sm border border-border bg-card shadow-default'>
|
||||
<div className='border-b border-border px-7 py-4'>
|
||||
<h3 className='font-medium text-foreground'>Button Sizes</h3>
|
||||
<div className="border-border bg-card shadow-default mb-10 rounded-sm border">
|
||||
<div className="border-border border-b px-7 py-4">
|
||||
<h3 className="text-foreground font-medium">Button Sizes</h3>
|
||||
</div>
|
||||
|
||||
<div className='p-4 md:p-6 xl:p-9'>
|
||||
<div className='flex flex-wrap items-center gap-4'>
|
||||
<Button size='sm'>Small</Button>
|
||||
<Button size='default'>Default</Button>
|
||||
<Button size='lg'>Large</Button>
|
||||
<Button size='icon'>
|
||||
<div className="p-4 md:p-6 xl:p-9">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="default">Default</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
<Button size="icon">
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -46,22 +46,22 @@ const Buttons = ({ user }: { user: AuthUser }) => {
|
||||
</div>
|
||||
|
||||
{/* Button with Icon */}
|
||||
<div className='mb-10 rounded-sm border border-border bg-card shadow-default'>
|
||||
<div className='border-b border-border px-7 py-4'>
|
||||
<h3 className='font-medium text-foreground'>Button with Icon</h3>
|
||||
<div className="border-border bg-card shadow-default mb-10 rounded-sm border">
|
||||
<div className="border-border border-b px-7 py-4">
|
||||
<h3 className="text-foreground font-medium">Button with Icon</h3>
|
||||
</div>
|
||||
|
||||
<div className='p-4 md:p-6 xl:p-9'>
|
||||
<div className='flex flex-wrap gap-4'>
|
||||
<div className="p-4 md:p-6 xl:p-9">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button>
|
||||
<Plus />
|
||||
Add Item
|
||||
</Button>
|
||||
<Button variant='outline'>
|
||||
<Button variant="outline">
|
||||
<Heart />
|
||||
Like
|
||||
</Button>
|
||||
<Button variant='destructive'>
|
||||
<Button variant="destructive">
|
||||
<Trash2 />
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
interface BreadcrumbProps {
|
||||
pageName: string;
|
||||
}
|
||||
const Breadcrumb = ({ pageName }: BreadcrumbProps) => {
|
||||
return (
|
||||
<div className='mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<h2 className='text-title-md2 font-semibold text-foreground'>{pageName}</h2>
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-title-md2 text-foreground font-semibold">
|
||||
{pageName}
|
||||
</h2>
|
||||
|
||||
<nav>
|
||||
<ul className='flex items-center gap-1'>
|
||||
<ul className="flex items-center gap-1">
|
||||
<li>
|
||||
<WaspRouterLink to={routes.AdminRoute.to}>Dashboard</WaspRouterLink>
|
||||
</li>
|
||||
<li>/</li>
|
||||
<li className='font-medium'>{pageName}</li>
|
||||
<li className="font-medium">{pageName}</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import { FC, ReactNode, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
import { FC, ReactNode, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import Header from "./Header";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
interface Props {
|
||||
user: AuthUser;
|
||||
@@ -13,17 +13,23 @@ const DefaultLayout: FC<Props> = ({ children, user }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
if (!user.isAdmin) {
|
||||
return <Navigate to='/' replace />;
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='bg-background text-foreground'>
|
||||
<div className='flex h-screen overflow-hidden'>
|
||||
<div className="bg-background text-foreground">
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||||
<div className='relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden'>
|
||||
<Header sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} user={user} />
|
||||
<div className="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden">
|
||||
<Header
|
||||
sidebarOpen={sidebarOpen}
|
||||
setSidebarOpen={setSidebarOpen}
|
||||
user={user}
|
||||
/>
|
||||
<main>
|
||||
<div className='mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10'>{children}</div>
|
||||
<div className="mx-auto max-w-screen-2xl p-4 md:p-6 2xl:p-10">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type AuthUser } from 'wasp/auth';
|
||||
import DarkModeSwitcher from '../../client/components/DarkModeSwitcher';
|
||||
import { cn } from '../../lib/utils';
|
||||
import MessageButton from '../dashboards/messages/MessageButton';
|
||||
import { UserDropdown } from '../../user/UserDropdown';
|
||||
import { type AuthUser } from "wasp/auth";
|
||||
import DarkModeSwitcher from "../../client/components/DarkModeSwitcher";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { UserDropdown } from "../../user/UserDropdown";
|
||||
import MessageButton from "../dashboards/messages/MessageButton";
|
||||
|
||||
const Header = (props: {
|
||||
sidebarOpen: string | boolean | undefined;
|
||||
@@ -10,61 +10,61 @@ const Header = (props: {
|
||||
user: AuthUser;
|
||||
}) => {
|
||||
return (
|
||||
<header className='sticky top-0 z-10 flex w-full bg-background border-b border-border shadow-sm'>
|
||||
<div className='flex flex-grow items-center justify-between sm:justify-end sm:gap-5 px-8 py-5'>
|
||||
<div className='flex items-center gap-2 sm:gap-4 lg:hidden'>
|
||||
<header className="bg-background border-border sticky top-0 z-10 flex w-full border-b shadow-sm">
|
||||
<div className="flex flex-grow items-center justify-between px-8 py-5 sm:justify-end sm:gap-5">
|
||||
<div className="flex items-center gap-2 sm:gap-4 lg:hidden">
|
||||
{/* <!-- Hamburger Toggle BTN --> */}
|
||||
|
||||
<button
|
||||
aria-controls='sidebar'
|
||||
aria-controls="sidebar"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.setSidebarOpen(!props.sidebarOpen);
|
||||
}}
|
||||
className='z-99999 block rounded-sm border border-border bg-background p-1.5 shadow-sm lg:hidden'
|
||||
className="z-99999 border-border bg-background block rounded-sm border p-1.5 shadow-sm lg:hidden"
|
||||
>
|
||||
<span className='relative block h-5.5 w-5.5 cursor-pointer'>
|
||||
<span className='du-block absolute right-0 h-full w-full'>
|
||||
<span className="h-5.5 w-5.5 relative block cursor-pointer">
|
||||
<span className="du-block absolute right-0 h-full w-full">
|
||||
<span
|
||||
className={cn(
|
||||
'relative top-0 left-0 my-1 block h-0.5 w-0 rounded-sm bg-foreground delay-[0] duration-200 ease-in-out',
|
||||
"bg-foreground relative left-0 top-0 my-1 block h-0.5 w-0 rounded-sm delay-[0] duration-200 ease-in-out",
|
||||
{
|
||||
'!w-full delay-300': !props.sidebarOpen,
|
||||
}
|
||||
"!w-full delay-300": !props.sidebarOpen,
|
||||
},
|
||||
)}
|
||||
></span>
|
||||
<span
|
||||
className={cn(
|
||||
'relative top-0 left-0 my-1 block h-0.5 w-0 rounded-sm bg-foreground delay-150 duration-200 ease-in-out',
|
||||
"bg-foreground relative left-0 top-0 my-1 block h-0.5 w-0 rounded-sm delay-150 duration-200 ease-in-out",
|
||||
{
|
||||
'delay-400 !w-full': !props.sidebarOpen,
|
||||
}
|
||||
"delay-400 !w-full": !props.sidebarOpen,
|
||||
},
|
||||
)}
|
||||
></span>
|
||||
<span
|
||||
className={cn(
|
||||
'relative top-0 left-0 my-1 block h-0.5 w-0 rounded-sm bg-foreground delay-200 duration-200 ease-in-out',
|
||||
"bg-foreground relative left-0 top-0 my-1 block h-0.5 w-0 rounded-sm delay-200 duration-200 ease-in-out",
|
||||
{
|
||||
'!w-full delay-500': !props.sidebarOpen,
|
||||
}
|
||||
"!w-full delay-500": !props.sidebarOpen,
|
||||
},
|
||||
)}
|
||||
></span>
|
||||
</span>
|
||||
<span className='absolute right-0 h-full w-full rotate-45'>
|
||||
<span className="absolute right-0 h-full w-full rotate-45">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute left-2.5 top-0 block h-full w-0.5 rounded-sm bg-foreground delay-300 duration-200 ease-in-out',
|
||||
"bg-foreground absolute left-2.5 top-0 block h-full w-0.5 rounded-sm delay-300 duration-200 ease-in-out",
|
||||
{
|
||||
'!h-0 !delay-[0]': !props.sidebarOpen,
|
||||
}
|
||||
"!h-0 !delay-[0]": !props.sidebarOpen,
|
||||
},
|
||||
)}
|
||||
></span>
|
||||
<span
|
||||
className={cn(
|
||||
'delay-400 absolute left-0 top-2.5 block h-0.5 w-full rounded-sm bg-foreground duration-200 ease-in-out',
|
||||
"delay-400 bg-foreground absolute left-0 top-2.5 block h-0.5 w-full rounded-sm duration-200 ease-in-out",
|
||||
{
|
||||
'!h-0 !delay-200': !props.sidebarOpen,
|
||||
}
|
||||
"!h-0 !delay-200": !props.sidebarOpen,
|
||||
},
|
||||
)}
|
||||
></span>
|
||||
</span>
|
||||
@@ -74,7 +74,7 @@ const Header = (props: {
|
||||
{/* <!-- Hamburger Toggle BTN --> */}
|
||||
</div>
|
||||
|
||||
<ul className='flex items-center gap-2 2xsm:gap-4'>
|
||||
<ul className="2xsm:gap-4 flex items-center gap-2">
|
||||
{/* <!-- Dark Mode Toggler --> */}
|
||||
<DarkModeSwitcher />
|
||||
{/* <!-- Dark Mode Toggler --> */}
|
||||
@@ -84,7 +84,7 @@ const Header = (props: {
|
||||
{/* <!-- Chat Notification Area --> */}
|
||||
</ul>
|
||||
|
||||
<div className='flex items-center gap-3 2xsm:gap-7'>
|
||||
<div className="2xsm:gap-7 flex items-center gap-3">
|
||||
{/* <!-- User Area --> */}
|
||||
<UserDropdown user={props.user} />
|
||||
{/* <!-- User Area --> */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const LoadingSpinner = () => {
|
||||
return (
|
||||
<div className='flex py-10 items-center justify-center'>
|
||||
<div className='h-16 w-16 animate-spin rounded-full border-4 border-solid border-primary border-t-transparent'></div>
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="border-primary h-16 w-16 animate-spin rounded-full border-4 border-solid border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
Settings,
|
||||
Sheet,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import Logo from '../../client/static/logo.webp';
|
||||
import { cn } from '../../lib/utils';
|
||||
import SidebarLinkGroup from './SidebarLinkGroup';
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import Logo from "../../client/static/logo.webp";
|
||||
import { cn } from "../../lib/utils";
|
||||
import SidebarLinkGroup from "./SidebarLinkGroup";
|
||||
|
||||
interface SidebarProps {
|
||||
sidebarOpen: boolean;
|
||||
@@ -26,20 +26,25 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
||||
const trigger = useRef<any>(null);
|
||||
const sidebar = useRef<any>(null);
|
||||
|
||||
const storedSidebarExpanded = localStorage.getItem('sidebar-expanded');
|
||||
const storedSidebarExpanded = localStorage.getItem("sidebar-expanded");
|
||||
const [sidebarExpanded, setSidebarExpanded] = useState(
|
||||
storedSidebarExpanded === null ? false : storedSidebarExpanded === 'true'
|
||||
storedSidebarExpanded === null ? false : storedSidebarExpanded === "true",
|
||||
);
|
||||
|
||||
// close on click outside
|
||||
useEffect(() => {
|
||||
const clickHandler = ({ target }: MouseEvent) => {
|
||||
if (!sidebar.current || !trigger.current) return;
|
||||
if (!sidebarOpen || sidebar.current.contains(target) || trigger.current.contains(target)) return;
|
||||
if (
|
||||
!sidebarOpen ||
|
||||
sidebar.current.contains(target) ||
|
||||
trigger.current.contains(target)
|
||||
)
|
||||
return;
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
document.addEventListener('click', clickHandler);
|
||||
return () => document.removeEventListener('click', clickHandler);
|
||||
document.addEventListener("click", clickHandler);
|
||||
return () => document.removeEventListener("click", clickHandler);
|
||||
});
|
||||
|
||||
// close if the esc key is pressed
|
||||
@@ -48,16 +53,16 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
||||
if (!sidebarOpen || keyCode !== 27) return;
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', keyHandler);
|
||||
return () => document.removeEventListener('keydown', keyHandler);
|
||||
document.addEventListener("keydown", keyHandler);
|
||||
return () => document.removeEventListener("keydown", keyHandler);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebar-expanded', sidebarExpanded.toString());
|
||||
localStorage.setItem("sidebar-expanded", sidebarExpanded.toString());
|
||||
if (sidebarExpanded) {
|
||||
document.querySelector('body')?.classList.add('sidebar-expanded');
|
||||
document.querySelector("body")?.classList.add("sidebar-expanded");
|
||||
} else {
|
||||
document.querySelector('body')?.classList.remove('sidebar-expanded');
|
||||
document.querySelector("body")?.classList.remove("sidebar-expanded");
|
||||
}
|
||||
}, [sidebarExpanded]);
|
||||
|
||||
@@ -65,49 +70,51 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
||||
<aside
|
||||
ref={sidebar}
|
||||
className={cn(
|
||||
'absolute left-0 top-0 z-9999 flex h-screen w-72.5 flex-col overflow-y-hidden bg-muted border-r duration-300 ease-linear lg:static lg:translate-x-0',
|
||||
"z-9999 w-72.5 bg-muted absolute left-0 top-0 flex h-screen flex-col overflow-y-hidden border-r duration-300 ease-linear lg:static lg:translate-x-0",
|
||||
{
|
||||
'translate-x-0': sidebarOpen,
|
||||
'-translate-x-full': !sidebarOpen,
|
||||
}
|
||||
"translate-x-0": sidebarOpen,
|
||||
"-translate-x-full": !sidebarOpen,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{/* <!-- SIDEBAR HEADER --> */}
|
||||
<div className='flex items-center justify-between gap-2 px-6 py-5.5 lg:py-6.5'>
|
||||
<NavLink to='/'>
|
||||
<img src={Logo} alt='Logo' width={50} />
|
||||
<div className="py-5.5 lg:py-6.5 flex items-center justify-between gap-2 px-6">
|
||||
<NavLink to="/">
|
||||
<img src={Logo} alt="Logo" width={50} />
|
||||
</NavLink>
|
||||
|
||||
<button
|
||||
ref={trigger}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-controls='sidebar'
|
||||
aria-controls="sidebar"
|
||||
aria-expanded={sidebarOpen}
|
||||
className='block lg:hidden'
|
||||
className="block lg:hidden"
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
{/* <!-- SIDEBAR HEADER --> */}
|
||||
|
||||
<div className='no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear'>
|
||||
<div className="no-scrollbar flex flex-col overflow-y-auto duration-300 ease-linear">
|
||||
{/* <!-- Sidebar Menu --> */}
|
||||
<nav className='mt-5 py-4 px-4 lg:mt-9 lg:px-6'>
|
||||
<nav className="mt-5 px-4 py-4 lg:mt-9 lg:px-6">
|
||||
{/* <!-- Menu Group --> */}
|
||||
<div>
|
||||
<h3 className='mb-4 ml-4 text-sm font-semibold text-muted-foreground'>MENU</h3>
|
||||
<h3 className="text-muted-foreground mb-4 ml-4 text-sm font-semibold">
|
||||
MENU
|
||||
</h3>
|
||||
|
||||
<ul className='mb-6 flex flex-col gap-1.5'>
|
||||
<ul className="mb-6 flex flex-col gap-1.5">
|
||||
{/* <!-- Menu Item Dashboard --> */}
|
||||
<NavLink
|
||||
to='/admin'
|
||||
to="/admin"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
|
||||
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
|
||||
{
|
||||
'bg-accent text-accent-foreground': isActive,
|
||||
}
|
||||
"bg-accent text-accent-foreground": isActive,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -120,14 +127,14 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
||||
{/* <!-- Menu Item Users --> */}
|
||||
<li>
|
||||
<NavLink
|
||||
to='/admin/users'
|
||||
to="/admin/users"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
|
||||
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
|
||||
{
|
||||
'bg-accent text-accent-foreground': isActive,
|
||||
}
|
||||
"bg-accent text-accent-foreground": isActive,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -140,14 +147,14 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
||||
{/* <!-- Menu Item Settings --> */}
|
||||
<li>
|
||||
<NavLink
|
||||
to='/admin/settings'
|
||||
to="/admin/settings"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
|
||||
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
|
||||
{
|
||||
'bg-accent text-accent-foreground': isActive,
|
||||
}
|
||||
"bg-accent text-accent-foreground": isActive,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -161,20 +168,22 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
||||
|
||||
{/* <!-- Others Group --> */}
|
||||
<div>
|
||||
<h3 className='mb-4 ml-4 text-sm font-semibold text-muted-foreground'>Extra Components</h3>
|
||||
<h3 className="text-muted-foreground mb-4 ml-4 text-sm font-semibold">
|
||||
Extra Components
|
||||
</h3>
|
||||
|
||||
<ul className='mb-6 flex flex-col gap-1.5'>
|
||||
<ul className="mb-6 flex flex-col gap-1.5">
|
||||
{/* <!-- Menu Item Calendar --> */}
|
||||
<li>
|
||||
<NavLink
|
||||
to='/admin/calendar'
|
||||
to="/admin/calendar"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
|
||||
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
|
||||
{
|
||||
'bg-accent text-accent-foreground': isActive,
|
||||
}
|
||||
"bg-accent text-accent-foreground": isActive,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -185,21 +194,26 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
||||
{/* <!-- Menu Item Calendar --> */}
|
||||
|
||||
{/* <!-- Menu Item Ui Elements --> */}
|
||||
<SidebarLinkGroup activeCondition={pathname === '/ui' || pathname.includes('ui')}>
|
||||
<SidebarLinkGroup
|
||||
activeCondition={pathname === "/ui" || pathname.includes("ui")}
|
||||
>
|
||||
{(handleClick, open) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<NavLink
|
||||
to='#'
|
||||
to="#"
|
||||
className={cn(
|
||||
'group relative flex items-center gap-2.5 rounded-sm py-2 px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:bg-accent hover:text-accent-foreground',
|
||||
"text-muted-foreground hover:bg-accent hover:text-accent-foreground group relative flex items-center gap-2.5 rounded-sm px-4 py-2 font-medium duration-300 ease-in-out",
|
||||
{
|
||||
'bg-accent text-accent-foreground': pathname.includes('ui'),
|
||||
}
|
||||
"bg-accent text-accent-foreground":
|
||||
pathname.includes("ui"),
|
||||
},
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
sidebarExpanded ? handleClick() : setSidebarExpanded(true);
|
||||
sidebarExpanded
|
||||
? handleClick()
|
||||
: setSidebarExpanded(true);
|
||||
}}
|
||||
>
|
||||
<LayoutTemplate />
|
||||
@@ -207,16 +221,20 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen }: SidebarProps) => {
|
||||
{open ? <ChevronUp /> : <ChevronDown />}
|
||||
</NavLink>
|
||||
{/* <!-- Dropdown Menu Start --> */}
|
||||
<div className={cn('translate transform overflow-hidden', { hidden: !open })}>
|
||||
<ul className='mt-4 mb-5.5 flex flex-col gap-2.5 pl-6'>
|
||||
<div
|
||||
className={cn("translate transform overflow-hidden", {
|
||||
hidden: !open,
|
||||
})}
|
||||
>
|
||||
<ul className="mb-5.5 mt-4 flex flex-col gap-2.5 pl-6">
|
||||
<li>
|
||||
<NavLink
|
||||
to='/admin/ui/buttons'
|
||||
to="/admin/ui/buttons"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'group relative flex items-center gap-2.5 rounded-md px-4 font-medium text-muted-foreground duration-300 ease-in-out hover:text-accent',
|
||||
{ '!text-accent': isActive }
|
||||
"text-muted-foreground hover:text-accent group relative flex items-center gap-2.5 rounded-md px-4 font-medium duration-300 ease-in-out",
|
||||
{ "!text-accent": isActive },
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { ReactNode, useState } from "react";
|
||||
|
||||
interface SidebarLinkGroupProps {
|
||||
children: (handleClick: () => void, open: boolean) => ReactNode;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type DailyStats, type PageViewSource } from 'wasp/entities';
|
||||
import { HttpError, prisma } from 'wasp/server';
|
||||
import { type GetDailyStats } from 'wasp/server/operations';
|
||||
import { type DailyStats, type PageViewSource } from "wasp/entities";
|
||||
import { HttpError, prisma } from "wasp/server";
|
||||
import { type GetDailyStats } from "wasp/server/operations";
|
||||
|
||||
type DailyStatsWithSources = DailyStats & {
|
||||
sources: PageViewSource[];
|
||||
@@ -11,18 +11,27 @@ type DailyStatsValues = {
|
||||
weeklyStats: DailyStatsWithSources[];
|
||||
};
|
||||
|
||||
export const getDailyStats: GetDailyStats<void, DailyStatsValues | undefined> = async (_args, context) => {
|
||||
export const getDailyStats: GetDailyStats<
|
||||
void,
|
||||
DailyStatsValues | undefined
|
||||
> = async (_args, context) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
||||
throw new HttpError(
|
||||
401,
|
||||
"Only authenticated users are allowed to perform this operation",
|
||||
);
|
||||
}
|
||||
|
||||
if (!context.user.isAdmin) {
|
||||
throw new HttpError(403, 'Only admins are allowed to perform this operation');
|
||||
throw new HttpError(
|
||||
403,
|
||||
"Only admins are allowed to perform this operation",
|
||||
);
|
||||
}
|
||||
|
||||
const statsQuery = {
|
||||
orderBy: {
|
||||
date: 'desc',
|
||||
date: "desc",
|
||||
},
|
||||
include: {
|
||||
sources: true,
|
||||
@@ -35,7 +44,9 @@ export const getDailyStats: GetDailyStats<void, DailyStatsValues | undefined> =
|
||||
]);
|
||||
|
||||
if (!dailyStats) {
|
||||
console.log('\x1b[34mNote: No daily stats have been generated by the dailyStatsJob yet. \x1b[0m');
|
||||
console.log(
|
||||
"\x1b[34mNote: No daily stats have been generated by the dailyStatsJob yet. \x1b[0m",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { BetaAnalyticsDataClient } from '@google-analytics/data';
|
||||
import { BetaAnalyticsDataClient } from "@google-analytics/data";
|
||||
|
||||
const CLIENT_EMAIL = process.env.GOOGLE_ANALYTICS_CLIENT_EMAIL;
|
||||
const PRIVATE_KEY = Buffer.from(process.env.GOOGLE_ANALYTICS_PRIVATE_KEY!, 'base64').toString('utf-8');
|
||||
const PRIVATE_KEY = Buffer.from(
|
||||
process.env.GOOGLE_ANALYTICS_PRIVATE_KEY!,
|
||||
"base64",
|
||||
).toString("utf-8");
|
||||
const PROPERTY_ID = process.env.GOOGLE_ANALYTICS_PROPERTY_ID;
|
||||
|
||||
const analyticsDataClient = new BetaAnalyticsDataClient({
|
||||
@@ -16,19 +19,19 @@ export async function getSources() {
|
||||
property: `properties/${PROPERTY_ID}`,
|
||||
dateRanges: [
|
||||
{
|
||||
startDate: '2020-01-01',
|
||||
endDate: 'today',
|
||||
startDate: "2020-01-01",
|
||||
endDate: "today",
|
||||
},
|
||||
],
|
||||
// for a list of dimensions and metrics see https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema
|
||||
dimensions: [
|
||||
{
|
||||
name: 'source',
|
||||
name: "source",
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
name: 'activeUsers',
|
||||
name: "activeUsers",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -44,7 +47,7 @@ export async function getSources() {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('No response from Google Analytics');
|
||||
throw new Error("No response from Google Analytics");
|
||||
}
|
||||
|
||||
return activeUsersPerReferrer;
|
||||
@@ -65,13 +68,13 @@ async function getTotalPageViews() {
|
||||
property: `properties/${PROPERTY_ID}`,
|
||||
dateRanges: [
|
||||
{
|
||||
startDate: '2020-01-01', // go back to earliest date of your app
|
||||
endDate: 'today',
|
||||
startDate: "2020-01-01", // go back to earliest date of your app
|
||||
endDate: "today",
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
name: 'screenPageViews',
|
||||
name: "screenPageViews",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -80,7 +83,7 @@ async function getTotalPageViews() {
|
||||
// @ts-ignore
|
||||
totalViews = parseInt(response.rows[0].metricValues[0].value);
|
||||
} else {
|
||||
throw new Error('No response from Google Analytics');
|
||||
throw new Error("No response from Google Analytics");
|
||||
}
|
||||
return totalViews;
|
||||
}
|
||||
@@ -91,26 +94,26 @@ async function getPrevDayViewsChangePercent() {
|
||||
|
||||
dateRanges: [
|
||||
{
|
||||
startDate: '2daysAgo',
|
||||
endDate: 'yesterday',
|
||||
startDate: "2daysAgo",
|
||||
endDate: "yesterday",
|
||||
},
|
||||
],
|
||||
orderBys: [
|
||||
{
|
||||
dimension: {
|
||||
dimensionName: 'date',
|
||||
dimensionName: "date",
|
||||
},
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
dimensions: [
|
||||
{
|
||||
name: 'date',
|
||||
name: "date",
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
name: 'screenPageViews',
|
||||
name: "screenPageViews",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -128,14 +131,17 @@ async function getPrevDayViewsChangePercent() {
|
||||
viewsFromYesterday = parseInt(viewsFromYesterday);
|
||||
viewsFromDayBeforeYesterday = parseInt(viewsFromDayBeforeYesterday);
|
||||
if (viewsFromYesterday === 0 || viewsFromDayBeforeYesterday === 0) {
|
||||
return '0';
|
||||
return "0";
|
||||
}
|
||||
console.table({ viewsFromYesterday, viewsFromDayBeforeYesterday });
|
||||
|
||||
const change = ((viewsFromYesterday - viewsFromDayBeforeYesterday) / viewsFromDayBeforeYesterday) * 100;
|
||||
const change =
|
||||
((viewsFromYesterday - viewsFromDayBeforeYesterday) /
|
||||
viewsFromDayBeforeYesterday) *
|
||||
100;
|
||||
return change.toFixed(0);
|
||||
}
|
||||
} else {
|
||||
return '0';
|
||||
return "0";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ const PLAUSIBLE_SITE_ID = process.env.PLAUSIBLE_SITE_ID!;
|
||||
const PLAUSIBLE_BASE_URL = process.env.PLAUSIBLE_BASE_URL;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${PLAUSIBLE_API_KEY}`,
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ type PageViewSourcesResult = {
|
||||
{
|
||||
source: string;
|
||||
visitors: number;
|
||||
}
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -38,12 +38,12 @@ async function getTotalPageViews() {
|
||||
const response = await fetch(
|
||||
`${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&metrics=pageviews`,
|
||||
{
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${PLAUSIBLE_API_KEY}`,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
@@ -56,12 +56,19 @@ async function getTotalPageViews() {
|
||||
async function getPrevDayViewsChangePercent() {
|
||||
// Calculate today, yesterday, and the day before yesterday's dates
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today.setDate(today.getDate() - 1)).toISOString().split('T')[0];
|
||||
const dayBeforeYesterday = new Date(new Date().setDate(new Date().getDate() - 2)).toISOString().split('T')[0];
|
||||
const yesterday = new Date(today.setDate(today.getDate() - 1))
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
const dayBeforeYesterday = new Date(
|
||||
new Date().setDate(new Date().getDate() - 2),
|
||||
)
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
|
||||
// Fetch page views for yesterday and the day before yesterday
|
||||
const pageViewsYesterday = await getPageviewsForDate(yesterday);
|
||||
const pageViewsDayBeforeYesterday = await getPageviewsForDate(dayBeforeYesterday);
|
||||
const pageViewsDayBeforeYesterday =
|
||||
await getPageviewsForDate(dayBeforeYesterday);
|
||||
|
||||
console.table({
|
||||
pageViewsYesterday,
|
||||
@@ -72,9 +79,12 @@ async function getPrevDayViewsChangePercent() {
|
||||
|
||||
let change = 0;
|
||||
if (pageViewsYesterday === 0 || pageViewsDayBeforeYesterday === 0) {
|
||||
return '0';
|
||||
return "0";
|
||||
} else {
|
||||
change = ((pageViewsYesterday - pageViewsDayBeforeYesterday) / pageViewsDayBeforeYesterday) * 100;
|
||||
change =
|
||||
((pageViewsYesterday - pageViewsDayBeforeYesterday) /
|
||||
pageViewsDayBeforeYesterday) *
|
||||
100;
|
||||
}
|
||||
return change.toFixed(0);
|
||||
}
|
||||
@@ -82,7 +92,7 @@ async function getPrevDayViewsChangePercent() {
|
||||
async function getPageviewsForDate(date: string) {
|
||||
const url = `${PLAUSIBLE_BASE_URL}/v1/stats/aggregate?site_id=${PLAUSIBLE_SITE_ID}&period=day&date=${date}&metrics=pageviews`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
headers: headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -95,7 +105,7 @@ async function getPageviewsForDate(date: string) {
|
||||
export async function getSources() {
|
||||
const url = `${PLAUSIBLE_BASE_URL}/v1/stats/breakdown?site_id=${PLAUSIBLE_SITE_ID}&property=visit:source&metrics=visitors`;
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
method: "GET",
|
||||
headers: headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { type DailyStats } from 'wasp/entities';
|
||||
import { type DailyStatsJob } from 'wasp/server/jobs';
|
||||
import Stripe from 'stripe';
|
||||
import { stripe } from '../payment/stripe/stripeClient';
|
||||
import { listOrders } from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils';
|
||||
import { listOrders } from "@lemonsqueezy/lemonsqueezy.js";
|
||||
import Stripe from "stripe";
|
||||
import { type DailyStats } from "wasp/entities";
|
||||
import { type DailyStatsJob } from "wasp/server/jobs";
|
||||
import { stripe } from "../payment/stripe/stripeClient";
|
||||
import {
|
||||
getDailyPageViews,
|
||||
getSources,
|
||||
} from "./providers/plausibleAnalyticsUtils";
|
||||
// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
|
||||
import { paymentProcessor } from '../payment/paymentProcessor';
|
||||
import { SubscriptionStatus } from '../payment/plans';
|
||||
import { paymentProcessor } from "../payment/paymentProcessor";
|
||||
import { SubscriptionStatus } from "../payment/plans";
|
||||
|
||||
export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?: DailyStats[]; isLoading?: boolean };
|
||||
export type DailyStatsProps = {
|
||||
dailyStats?: DailyStats;
|
||||
weeklyStats?: DailyStats[];
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, context) => {
|
||||
export const calculateDailyStats: DailyStatsJob<never, void> = async (
|
||||
_args,
|
||||
context,
|
||||
) => {
|
||||
const nowUTC = new Date(Date.now());
|
||||
nowUTC.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
@@ -44,14 +54,16 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
||||
|
||||
let totalRevenue;
|
||||
switch (paymentProcessor.id) {
|
||||
case 'stripe':
|
||||
case "stripe":
|
||||
totalRevenue = await fetchTotalStripeRevenue();
|
||||
break;
|
||||
case 'lemonsqueezy':
|
||||
case "lemonsqueezy":
|
||||
totalRevenue = await fetchTotalLemonSqueezyRevenue();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported payment processor: ${paymentProcessor.id}`);
|
||||
throw new Error(
|
||||
`Unsupported payment processor: ${paymentProcessor.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews();
|
||||
@@ -63,7 +75,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
||||
});
|
||||
|
||||
if (!dailyStats) {
|
||||
console.log('No daily stat found for today, creating one...');
|
||||
console.log("No daily stat found for today, creating one...");
|
||||
dailyStats = await context.entities.DailyStats.create({
|
||||
data: {
|
||||
date: nowUTC,
|
||||
@@ -77,7 +89,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.log('Daily stat found for today, updating it...');
|
||||
console.log("Daily stat found for today, updating it...");
|
||||
dailyStats = await context.entities.DailyStats.update({
|
||||
where: {
|
||||
id: dailyStats.id,
|
||||
@@ -97,7 +109,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
||||
|
||||
for (const source of sources) {
|
||||
let visitors = source.visitors;
|
||||
if (typeof source.visitors !== 'number') {
|
||||
if (typeof source.visitors !== "number") {
|
||||
visitors = parseInt(source.visitors);
|
||||
}
|
||||
await context.entities.PageViewSource.upsert({
|
||||
@@ -121,11 +133,11 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
|
||||
|
||||
console.table({ dailyStats });
|
||||
} catch (error: any) {
|
||||
console.error('Error calculating daily stats: ', error);
|
||||
console.error("Error calculating daily stats: ", error);
|
||||
await context.entities.Logs.create({
|
||||
data: {
|
||||
message: `Error calculating daily stats: ${error?.message}`,
|
||||
level: 'job-error',
|
||||
level: "job-error",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -139,7 +151,7 @@ async function fetchTotalStripeRevenue() {
|
||||
// gte: startTimestamp,
|
||||
// lt: endTimestamp
|
||||
// },
|
||||
type: 'charge',
|
||||
type: "charge",
|
||||
};
|
||||
|
||||
let hasMore = true;
|
||||
@@ -147,14 +159,15 @@ async function fetchTotalStripeRevenue() {
|
||||
const balanceTransactions = await stripe.balanceTransactions.list(params);
|
||||
|
||||
for (const transaction of balanceTransactions.data) {
|
||||
if (transaction.type === 'charge') {
|
||||
if (transaction.type === "charge") {
|
||||
totalRevenue += transaction.amount;
|
||||
}
|
||||
}
|
||||
|
||||
if (balanceTransactions.has_more) {
|
||||
// Set the starting point for the next iteration to the last object fetched
|
||||
params.starting_after = balanceTransactions.data[balanceTransactions.data.length - 1].id;
|
||||
params.starting_after =
|
||||
balanceTransactions.data[balanceTransactions.data.length - 1].id;
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
@@ -194,7 +207,7 @@ async function fetchTotalLemonSqueezyRevenue() {
|
||||
// Revenue is in cents so we convert to dollars (or your main currency unit)
|
||||
return totalRevenue / 100;
|
||||
} catch (error) {
|
||||
console.error('Error fetching Lemon Squeezy revenue:', error);
|
||||
console.error("Error fetching Lemon Squeezy revenue:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function AuthPageLayout({children} : {children: ReactNode }) {
|
||||
export function AuthPageLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className='flex min-h-full flex-col justify-center pt-10 sm:px-6 lg:px-8'>
|
||||
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
|
||||
<div className='bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10 dark:bg-white dark:text-gray-900'>
|
||||
<div className='-mt-8'>
|
||||
{ children }
|
||||
</div>
|
||||
<div className="flex min-h-full flex-col justify-center pt-10 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white px-4 py-8 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10 dark:bg-white dark:text-gray-900">
|
||||
<div className="-mt-8">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { LoginForm } from 'wasp/client/auth';
|
||||
import { AuthPageLayout } from './AuthPageLayout';
|
||||
import { LoginForm } from "wasp/client/auth";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
import { AuthPageLayout } from "./AuthPageLayout";
|
||||
|
||||
export default function Login() {
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<LoginForm />
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900 dark:text-gray-900'>
|
||||
Don't have an account yet?{' '}
|
||||
<WaspRouterLink to={routes.SignupRoute.to} className='underline'>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-900">
|
||||
Don't have an account yet?{" "}
|
||||
<WaspRouterLink to={routes.SignupRoute.to} className="underline">
|
||||
go to signup
|
||||
</WaspRouterLink>
|
||||
.
|
||||
</span>
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
Forgot your password?{' '}
|
||||
<WaspRouterLink to={routes.RequestPasswordResetRoute.to} className='underline'>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
Forgot your password?{" "}
|
||||
<WaspRouterLink
|
||||
to={routes.RequestPasswordResetRoute.to}
|
||||
className="underline"
|
||||
>
|
||||
reset it
|
||||
</WaspRouterLink>
|
||||
.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { SignupForm } from 'wasp/client/auth';
|
||||
import { AuthPageLayout } from './AuthPageLayout';
|
||||
import { SignupForm } from "wasp/client/auth";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
import { AuthPageLayout } from "./AuthPageLayout";
|
||||
|
||||
export function Signup() {
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<SignupForm />
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
I already have an account (
|
||||
<WaspRouterLink to={routes.LoginRoute.to} className='underline'>
|
||||
<WaspRouterLink to={routes.LoginRoute.to} className="underline">
|
||||
go to login
|
||||
</WaspRouterLink>
|
||||
).
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { VerifyEmailForm } from 'wasp/client/auth';
|
||||
import { AuthPageLayout } from '../AuthPageLayout';
|
||||
import { VerifyEmailForm } from "wasp/client/auth";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
import { AuthPageLayout } from "../AuthPageLayout";
|
||||
|
||||
export function EmailVerificationPage() {
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<VerifyEmailForm />
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
If everything is okay, <WaspRouterLink to={routes.LoginRoute.to} className='underline'>go to login</WaspRouterLink>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
If everything is okay,{" "}
|
||||
<WaspRouterLink to={routes.LoginRoute.to} className="underline">
|
||||
go to login
|
||||
</WaspRouterLink>
|
||||
</span>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { ResetPasswordForm } from 'wasp/client/auth';
|
||||
import { AuthPageLayout } from '../AuthPageLayout';
|
||||
import { ResetPasswordForm } from "wasp/client/auth";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
import { AuthPageLayout } from "../AuthPageLayout";
|
||||
|
||||
export function PasswordResetPage() {
|
||||
return (
|
||||
<AuthPageLayout>
|
||||
<ResetPasswordForm />
|
||||
<br />
|
||||
<span className='text-sm font-medium text-gray-900'>
|
||||
If everything is okay, <WaspRouterLink to={routes.LoginRoute.to}>go to login</WaspRouterLink>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
If everything is okay,{" "}
|
||||
<WaspRouterLink to={routes.LoginRoute.to}>go to login</WaspRouterLink>
|
||||
</span>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ForgotPasswordForm } from 'wasp/client/auth';
|
||||
import { AuthPageLayout } from '../AuthPageLayout';
|
||||
import { ForgotPasswordForm } from "wasp/client/auth";
|
||||
import { AuthPageLayout } from "../AuthPageLayout";
|
||||
|
||||
export function RequestPasswordResetPage() {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { type GetVerificationEmailContentFn, type GetPasswordResetEmailContentFn } from 'wasp/server/auth';
|
||||
import {
|
||||
type GetPasswordResetEmailContentFn,
|
||||
type GetVerificationEmailContentFn,
|
||||
} from "wasp/server/auth";
|
||||
|
||||
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ verificationLink }) => ({
|
||||
subject: 'Verify your email',
|
||||
export const getVerificationEmailContent: GetVerificationEmailContentFn = ({
|
||||
verificationLink,
|
||||
}) => ({
|
||||
subject: "Verify your email",
|
||||
text: `Click the link below to verify your email: ${verificationLink}`,
|
||||
html: `
|
||||
<p>Click the link below to verify your email</p>
|
||||
@@ -9,8 +14,10 @@ export const getVerificationEmailContent: GetVerificationEmailContentFn = ({ ver
|
||||
`,
|
||||
});
|
||||
|
||||
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ passwordResetLink }) => ({
|
||||
subject: 'Password reset',
|
||||
export const getPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({
|
||||
passwordResetLink,
|
||||
}) => ({
|
||||
subject: "Password reset",
|
||||
text: `Click the link below to reset your password: ${passwordResetLink}`,
|
||||
html: `
|
||||
<p>Click the link below to reset your password</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { defineUserSignupFields } from 'wasp/auth/providers/types';
|
||||
import { defineUserSignupFields } from "wasp/auth/providers/types";
|
||||
import { z } from "zod";
|
||||
|
||||
const adminEmails = process.env.ADMIN_EMAILS?.split(',') || [];
|
||||
const adminEmails = process.env.ADMIN_EMAILS?.split(",") || [];
|
||||
|
||||
const emailDataSchema = z.object({
|
||||
email: z.string(),
|
||||
@@ -29,9 +29,12 @@ const githubDataSchema = z.object({
|
||||
z.object({
|
||||
email: z.string(),
|
||||
verified: z.boolean(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
.min(1, 'You need to have an email address associated with your GitHub account to sign up.'),
|
||||
.min(
|
||||
1,
|
||||
"You need to have an email address associated with your GitHub account to sign up.",
|
||||
),
|
||||
login: z.string(),
|
||||
}),
|
||||
});
|
||||
@@ -65,7 +68,7 @@ function getGithubEmailInfo(githubData: z.infer<typeof githubDataSchema>) {
|
||||
// instead of ["user"] and access args.profile.username instead
|
||||
export function getGitHubAuthConfig() {
|
||||
return {
|
||||
scopes: ['user'],
|
||||
scopes: ["user"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,7 +99,7 @@ export const getGoogleUserFields = defineUserSignupFields({
|
||||
|
||||
export function getGoogleAuthConfig() {
|
||||
return {
|
||||
scopes: ['profile', 'email'], // must include at least 'profile' for Google
|
||||
scopes: ["profile", "email"], // must include at least 'profile' for Google
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,7 +116,9 @@ export const getDiscordUserFields = defineUserSignupFields({
|
||||
const discordData = discordDataSchema.parse(data);
|
||||
// Users need to have an email for payment processing.
|
||||
if (!discordData.profile.email) {
|
||||
throw new Error('You need to have an email address associated with your Discord account to sign up.');
|
||||
throw new Error(
|
||||
"You need to have an email address associated with your Discord account to sign up.",
|
||||
);
|
||||
}
|
||||
return discordData.profile.email;
|
||||
},
|
||||
@@ -132,6 +137,6 @@ export const getDiscordUserFields = defineUserSignupFields({
|
||||
|
||||
export function getDiscordAuthConfig() {
|
||||
return {
|
||||
scopes: ['identify', 'email'],
|
||||
scopes: ["identify", "email"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { routes } from 'wasp/client/router';
|
||||
import './Main.css';
|
||||
import NavBar from './components/NavBar/NavBar';
|
||||
import { demoNavigationitems, marketingNavigationItems } from './components/NavBar/constants';
|
||||
import CookieConsentBanner from './components/cookie-consent/Banner';
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { routes } from "wasp/client/router";
|
||||
import "./Main.css";
|
||||
import NavBar from "./components/NavBar/NavBar";
|
||||
import {
|
||||
demoNavigationitems,
|
||||
marketingNavigationItems,
|
||||
} from "./components/NavBar/constants";
|
||||
import CookieConsentBanner from "./components/cookie-consent/Banner";
|
||||
|
||||
/**
|
||||
* use this component to wrap all child components
|
||||
@@ -13,24 +16,29 @@ import CookieConsentBanner from './components/cookie-consent/Banner';
|
||||
export default function App() {
|
||||
const location = useLocation();
|
||||
const isMarketingPage = useMemo(() => {
|
||||
return location.pathname === '/' || location.pathname.startsWith('/pricing');
|
||||
return (
|
||||
location.pathname === "/" || location.pathname.startsWith("/pricing")
|
||||
);
|
||||
}, [location]);
|
||||
|
||||
const navigationItems = isMarketingPage ? marketingNavigationItems : demoNavigationitems;
|
||||
const navigationItems = isMarketingPage
|
||||
? marketingNavigationItems
|
||||
: demoNavigationitems;
|
||||
|
||||
const shouldDisplayAppNavBar = useMemo(() => {
|
||||
return (
|
||||
location.pathname !== routes.LoginRoute.build() && location.pathname !== routes.SignupRoute.build()
|
||||
location.pathname !== routes.LoginRoute.build() &&
|
||||
location.pathname !== routes.SignupRoute.build()
|
||||
);
|
||||
}, [location]);
|
||||
|
||||
const isAdminDashboard = useMemo(() => {
|
||||
return location.pathname.startsWith('/admin');
|
||||
return location.pathname.startsWith("/admin");
|
||||
}, [location]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash) {
|
||||
const id = location.hash.replace('#', '');
|
||||
const id = location.hash.replace("#", "");
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView();
|
||||
@@ -40,13 +48,15 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='min-h-screen bg-background text-foreground'>
|
||||
<div className="bg-background text-foreground min-h-screen">
|
||||
{isAdminDashboard ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<>
|
||||
{shouldDisplayAppNavBar && <NavBar navigationItems={navigationItems} />}
|
||||
<div className='mx-auto max-w-screen-2xl'>
|
||||
{shouldDisplayAppNavBar && (
|
||||
<NavBar navigationItems={navigationItems} />
|
||||
)}
|
||||
<div className="mx-auto max-w-screen-2xl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -22,14 +22,22 @@
|
||||
|
||||
/* Text gradient utilities */
|
||||
.text-gradient-primary {
|
||||
background: linear-gradient(to right, hsl(var(--secondary-muted)), hsl(var(--secondary)));
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
hsl(var(--secondary-muted)),
|
||||
hsl(var(--secondary))
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.text-gradient-primary-diagonal {
|
||||
background: linear-gradient(135deg, hsl(var(--secondary-muted)), hsl(var(--secondary)));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--secondary-muted)),
|
||||
hsl(var(--secondary))
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
@@ -37,7 +45,11 @@
|
||||
|
||||
/* Border gradient utilities */
|
||||
.border-gradient-primary {
|
||||
background: linear-gradient(to right, hsl(var(--secondary-muted)), hsl(var(--secondary)));
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
hsl(var(--secondary-muted)),
|
||||
hsl(var(--secondary))
|
||||
);
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
@@ -52,8 +64,8 @@
|
||||
* under `theme.extend.fontFamily`, and then can be used as a tailwind class, e.g. className='font-satoshi'.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Satoshi';
|
||||
src: url('/fonts/Satoshi-Regular.woff2') format('woff2');
|
||||
font-family: "Satoshi";
|
||||
src: url("/fonts/Satoshi-Regular.woff2") format("woff2");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { cn } from '../../lib/utils';
|
||||
import useColorMode from '../hooks/useColorMode';
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { cn } from "../../lib/utils";
|
||||
import useColorMode from "../hooks/useColorMode";
|
||||
|
||||
const DarkModeSwitcher = () => {
|
||||
const [colorMode, setColorMode] = useColorMode();
|
||||
const isInLightMode = colorMode === 'light';
|
||||
const isInLightMode = colorMode === "light";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label
|
||||
className={cn(
|
||||
'relative m-0 block h-7.5 w-14 rounded-full transition-colors duration-300 ease-in-out cursor-pointer bg-muted'
|
||||
"h-7.5 bg-muted relative m-0 block w-14 cursor-pointer rounded-full transition-colors duration-300 ease-in-out",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type='checkbox'
|
||||
type="checkbox"
|
||||
onChange={() => {
|
||||
if (typeof setColorMode === 'function') {
|
||||
setColorMode(isInLightMode ? 'dark' : 'light');
|
||||
if (typeof setColorMode === "function") {
|
||||
setColorMode(isInLightMode ? "dark" : "light");
|
||||
}
|
||||
}}
|
||||
className='absolute top-0 z-50 m-0 h-full w-full cursor-pointer opacity-0'
|
||||
className="absolute top-0 z-50 m-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-1/2 left-[3px] flex h-6 w-6 -translate-y-1/2 translate-x-0 items-center justify-center rounded-full bg-white shadow-md border border-border transition-all duration-300 ease-in-out',
|
||||
"border-border absolute left-[3px] top-1/2 flex h-6 w-6 -translate-y-1/2 translate-x-0 items-center justify-center rounded-full border bg-white shadow-md transition-all duration-300 ease-in-out",
|
||||
{
|
||||
'!right-[3px] !translate-x-full': !isInLightMode,
|
||||
}
|
||||
"!right-[3px] !translate-x-full": !isInLightMode,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<ModeIcon isInLightMode={isInLightMode} />
|
||||
@@ -40,14 +40,18 @@ const DarkModeSwitcher = () => {
|
||||
|
||||
function ModeIcon({ isInLightMode }: { isInLightMode: boolean }) {
|
||||
const iconStyle =
|
||||
'absolute inset-0 flex items-center justify-center transition-opacity ease-in-out duration-300';
|
||||
"absolute inset-0 flex items-center justify-center transition-opacity ease-in-out duration-300";
|
||||
return (
|
||||
<>
|
||||
<span className={cn(iconStyle, isInLightMode ? 'opacity-100' : 'opacity-0')}>
|
||||
<Sun className='size-4 stroke-amber-500 fill-amber-500' />
|
||||
<span
|
||||
className={cn(iconStyle, isInLightMode ? "opacity-100" : "opacity-0")}
|
||||
>
|
||||
<Sun className="size-4 fill-amber-500 stroke-amber-500" />
|
||||
</span>
|
||||
<span className={cn(iconStyle, !isInLightMode ? 'opacity-100' : 'opacity-0')}>
|
||||
<Moon className='size-4 stroke-slate-600 fill-slate-600' />
|
||||
<span
|
||||
className={cn(iconStyle, !isInLightMode ? "opacity-100" : "opacity-0")}
|
||||
>
|
||||
<Moon className="size-4 fill-slate-600 stroke-slate-600" />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
const ANNOUNCEMENT_URL = 'https://github.com/wasp-lang/wasp';
|
||||
const ANNOUNCEMENT_URL = "https://github.com/wasp-lang/wasp";
|
||||
|
||||
export function Announcement() {
|
||||
return (
|
||||
<div className='relative flex justify-center items-center gap-3 p-3 w-full bg-gradient-to-r from-accent to-secondary font-semibold text-primary-foreground text-center'>
|
||||
<div className="from-accent to-secondary text-primary-foreground relative flex w-full items-center justify-center gap-3 bg-gradient-to-r p-3 text-center font-semibold">
|
||||
<a
|
||||
href={ANNOUNCEMENT_URL}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='hidden lg:block cursor-pointer hover:opacity-90 hover:drop-shadow transition-opacity'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden cursor-pointer transition-opacity hover:opacity-90 hover:drop-shadow lg:block"
|
||||
>
|
||||
Support Open-Source Software!
|
||||
</a>
|
||||
<div className='hidden lg:block self-stretch w-0.5 bg-primary-foreground/20'></div>
|
||||
<div className="bg-primary-foreground/20 hidden w-0.5 self-stretch lg:block"></div>
|
||||
<a
|
||||
href={ANNOUNCEMENT_URL}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='hidden lg:block cursor-pointer rounded-full bg-background/20 px-2.5 py-1 text-xs hover:bg-background/30 transition-colors tracking-wider'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-background/20 hover:bg-background/30 hidden cursor-pointer rounded-full px-2.5 py-1 text-xs tracking-wider transition-colors lg:block"
|
||||
>
|
||||
Star Our Repo on Github ⭐️ →
|
||||
</a>
|
||||
<a
|
||||
href={ANNOUNCEMENT_URL}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='lg:hidden cursor-pointer rounded-full bg-background/20 px-2.5 py-1 text-xs hover:bg-background/30 transition-colors'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-background/20 hover:bg-background/30 cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:hidden"
|
||||
>
|
||||
⭐️ Star the Our Repo and Support Open-Source! ⭐️
|
||||
</a>
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { LogIn, Menu } from 'lucide-react';
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { Link as ReactRouterLink } from 'react-router-dom';
|
||||
import { useAuth } from 'wasp/client/auth';
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../../components/ui/sheet';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { throttleWithTrailingInvocation } from '../../../shared/utils';
|
||||
import { UserDropdown } from '../../../user/UserDropdown';
|
||||
import { UserMenuItems } from '../../../user/UserMenuItems';
|
||||
import { useIsLandingPage } from '../../hooks/useIsLandingPage';
|
||||
import logo from '../../static/logo.webp';
|
||||
import DarkModeSwitcher from '../DarkModeSwitcher';
|
||||
import { Announcement } from './Announcement';
|
||||
import { LogIn, Menu } from "lucide-react";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { Link as ReactRouterLink } from "react-router-dom";
|
||||
import { useAuth } from "wasp/client/auth";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "../../../components/ui/sheet";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { throttleWithTrailingInvocation } from "../../../shared/utils";
|
||||
import { UserDropdown } from "../../../user/UserDropdown";
|
||||
import { UserMenuItems } from "../../../user/UserMenuItems";
|
||||
import { useIsLandingPage } from "../../hooks/useIsLandingPage";
|
||||
import logo from "../../static/logo.webp";
|
||||
import DarkModeSwitcher from "../DarkModeSwitcher";
|
||||
import { Announcement } from "./Announcement";
|
||||
|
||||
export interface NavigationItem {
|
||||
name: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export default function NavBar({ navigationItems }: { navigationItems: NavigationItem[] }) {
|
||||
export default function NavBar({
|
||||
navigationItems,
|
||||
}: {
|
||||
navigationItems: NavigationItem[];
|
||||
}) {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const isLandingPage = useIsLandingPage();
|
||||
|
||||
@@ -27,10 +37,10 @@ export default function NavBar({ navigationItems }: { navigationItems: Navigatio
|
||||
setIsScrolled(window.scrollY > 0);
|
||||
}, 50);
|
||||
|
||||
window.addEventListener('scroll', throttledHandler);
|
||||
window.addEventListener("scroll", throttledHandler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', throttledHandler);
|
||||
window.removeEventListener("scroll", throttledHandler);
|
||||
throttledHandler.cancel();
|
||||
};
|
||||
}, []);
|
||||
@@ -38,42 +48,57 @@ export default function NavBar({ navigationItems }: { navigationItems: Navigatio
|
||||
return (
|
||||
<>
|
||||
{isLandingPage && <Announcement />}
|
||||
<header className={cn('sticky top-0 z-50 transition-all duration-300', isScrolled && 'top-4')}>
|
||||
<header
|
||||
className={cn(
|
||||
"sticky top-0 z-50 transition-all duration-300",
|
||||
isScrolled && "top-4",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('transition-all duration-300', {
|
||||
'mx-4 md:mx-20 pr-2 lg:pr-0 rounded-full shadow-lg bg-background/90 backdrop-blur-lg border border-border':
|
||||
className={cn("transition-all duration-300", {
|
||||
"bg-background/90 border-border mx-4 rounded-full border pr-2 shadow-lg backdrop-blur-lg md:mx-20 lg:pr-0":
|
||||
isScrolled,
|
||||
'mx-0 bg-background/80 backdrop-blur-lg border-b border-border': !isScrolled,
|
||||
"bg-background/80 border-border mx-0 border-b backdrop-blur-lg":
|
||||
!isScrolled,
|
||||
})}
|
||||
>
|
||||
<nav
|
||||
className={cn('flex items-center justify-between transition-all duration-300', {
|
||||
'p-3 lg:px-6': isScrolled,
|
||||
'p-6 lg:px-8': !isScrolled,
|
||||
})}
|
||||
aria-label='Global'
|
||||
className={cn(
|
||||
"flex items-center justify-between transition-all duration-300",
|
||||
{
|
||||
"p-3 lg:px-6": isScrolled,
|
||||
"p-6 lg:px-8": !isScrolled,
|
||||
},
|
||||
)}
|
||||
aria-label="Global"
|
||||
>
|
||||
<div className='flex items-center gap-6'>
|
||||
<div className="flex items-center gap-6">
|
||||
<WaspRouterLink
|
||||
to={routes.LandingPageRoute.to}
|
||||
className='flex items-center text-foreground duration-300 ease-in-out hover:text-primary transition-colors'
|
||||
className="text-foreground hover:text-primary flex items-center transition-colors duration-300 ease-in-out"
|
||||
>
|
||||
<NavLogo isScrolled={isScrolled} />
|
||||
<span
|
||||
className={cn('font-semibold leading-6 text-foreground transition-all duration-300', {
|
||||
'ml-2 text-sm': !isScrolled,
|
||||
'ml-2 text-xs': isScrolled,
|
||||
})}
|
||||
className={cn(
|
||||
"text-foreground font-semibold leading-6 transition-all duration-300",
|
||||
{
|
||||
"ml-2 text-sm": !isScrolled,
|
||||
"ml-2 text-xs": isScrolled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
Your SaaS
|
||||
</span>
|
||||
</WaspRouterLink>
|
||||
|
||||
<ul className='hidden lg:flex items-center gap-6 ml-4'>
|
||||
<ul className="ml-4 hidden items-center gap-6 lg:flex">
|
||||
{renderNavigationItems(navigationItems)}
|
||||
</ul>
|
||||
</div>
|
||||
<NavBarMobileMenu isScrolled={isScrolled} navigationItems={navigationItems} />
|
||||
<NavBarMobileMenu
|
||||
isScrolled={isScrolled}
|
||||
navigationItems={navigationItems}
|
||||
/>
|
||||
<NavBarDesktopUserDropdown isScrolled={isScrolled} />
|
||||
</nav>
|
||||
</div>
|
||||
@@ -86,31 +111,34 @@ function NavBarDesktopUserDropdown({ isScrolled }: { isScrolled: boolean }) {
|
||||
const { data: user, isLoading: isUserLoading } = useAuth();
|
||||
|
||||
return (
|
||||
<div className='hidden lg:flex lg:flex-1 gap-3 justify-end items-center'>
|
||||
<ul className='flex justify-center items-center gap-2 sm:gap-4'>
|
||||
<div className="hidden items-center justify-end gap-3 lg:flex lg:flex-1">
|
||||
<ul className="flex items-center justify-center gap-2 sm:gap-4">
|
||||
<DarkModeSwitcher />
|
||||
</ul>
|
||||
{isUserLoading ? null : !user ? (
|
||||
<WaspRouterLink
|
||||
to={routes.LoginRoute.to}
|
||||
className={cn('font-semibold leading-6 ml-3 transition-all duration-300', {
|
||||
'text-sm': !isScrolled,
|
||||
'text-xs': isScrolled,
|
||||
})}
|
||||
className={cn(
|
||||
"ml-3 font-semibold leading-6 transition-all duration-300",
|
||||
{
|
||||
"text-sm": !isScrolled,
|
||||
"text-xs": isScrolled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center duration-300 ease-in-out text-foreground hover:text-primary transition-colors'>
|
||||
Log in{' '}
|
||||
<div className="text-foreground hover:text-primary flex items-center transition-colors duration-300 ease-in-out">
|
||||
Log in{" "}
|
||||
<LogIn
|
||||
size={isScrolled ? '1rem' : '1.1rem'}
|
||||
className={cn('transition-all duration-300', {
|
||||
'ml-1 mt-[0.1rem]': !isScrolled,
|
||||
'ml-1': isScrolled,
|
||||
size={isScrolled ? "1rem" : "1.1rem"}
|
||||
className={cn("transition-all duration-300", {
|
||||
"ml-1 mt-[0.1rem]": !isScrolled,
|
||||
"ml-1": isScrolled,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</WaspRouterLink>
|
||||
) : (
|
||||
<div className='ml-3'>
|
||||
<div className="ml-3">
|
||||
<UserDropdown user={user} />
|
||||
</div>
|
||||
)}
|
||||
@@ -129,51 +157,56 @@ function NavBarMobileMenu({
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className='flex lg:hidden'>
|
||||
<div className="flex lg:hidden">
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-muted hover:bg-accent transition-colors'
|
||||
"text-muted-foreground hover:text-muted hover:bg-accent inline-flex items-center justify-center rounded-md transition-colors",
|
||||
)}
|
||||
>
|
||||
<span className='sr-only'>Open main menu</span>
|
||||
<span className="sr-only">Open main menu</span>
|
||||
<Menu
|
||||
className={cn('transition-all duration-300', {
|
||||
'size-8 p-1': !isScrolled,
|
||||
'size-6 p-0.5': isScrolled,
|
||||
className={cn("transition-all duration-300", {
|
||||
"size-8 p-1": !isScrolled,
|
||||
"size-6 p-0.5": isScrolled,
|
||||
})}
|
||||
aria-hidden='true'
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side='right' className='w-[300px] sm:w-[400px]'>
|
||||
<SheetContent side="right" className="w-[300px] sm:w-[400px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className='flex items-center'>
|
||||
<SheetTitle className="flex items-center">
|
||||
<WaspRouterLink to={routes.LandingPageRoute.to}>
|
||||
<span className='sr-only'>Your SaaS</span>
|
||||
<span className="sr-only">Your SaaS</span>
|
||||
<NavLogo isScrolled={false} />
|
||||
</WaspRouterLink>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className='mt-6 flow-root'>
|
||||
<div className='-my-6 divide-y divide-border'>
|
||||
<ul className='space-y-2 py-6'>{renderNavigationItems(navigationItems, setMobileMenuOpen)}</ul>
|
||||
<div className='py-6'>
|
||||
<div className="mt-6 flow-root">
|
||||
<div className="divide-border -my-6 divide-y">
|
||||
<ul className="space-y-2 py-6">
|
||||
{renderNavigationItems(navigationItems, setMobileMenuOpen)}
|
||||
</ul>
|
||||
<div className="py-6">
|
||||
{isUserLoading ? null : !user ? (
|
||||
<WaspRouterLink to={routes.LoginRoute.to}>
|
||||
<div className='flex justify-end items-center duration-300 ease-in-out text-foreground hover:text-primary transition-colors'>
|
||||
Log in <LogIn size='1.1rem' className='ml-1' />
|
||||
<div className="text-foreground hover:text-primary flex items-center justify-end transition-colors duration-300 ease-in-out">
|
||||
Log in <LogIn size="1.1rem" className="ml-1" />
|
||||
</div>
|
||||
</WaspRouterLink>
|
||||
) : (
|
||||
<ul className='space-y-2'>
|
||||
<UserMenuItems user={user} onItemClick={() => setMobileMenuOpen(false)} />
|
||||
<ul className="space-y-2">
|
||||
<UserMenuItems
|
||||
user={user}
|
||||
onItemClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className='py-6'>
|
||||
<div className="py-6">
|
||||
<DarkModeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,12 +219,12 @@ function NavBarMobileMenu({
|
||||
|
||||
function renderNavigationItems(
|
||||
navigationItems: NavigationItem[],
|
||||
setMobileMenuOpen?: Dispatch<SetStateAction<boolean>>
|
||||
setMobileMenuOpen?: Dispatch<SetStateAction<boolean>>,
|
||||
) {
|
||||
const menuStyles = cn({
|
||||
'block rounded-lg px-3 py-2 text-sm font-medium leading-7 text-foreground hover:bg-accent hover:text-accent-foreground transition-colors':
|
||||
"block rounded-lg px-3 py-2 text-sm font-medium leading-7 text-foreground hover:bg-accent hover:text-accent-foreground transition-colors":
|
||||
!!setMobileMenuOpen,
|
||||
'text-sm font-normal leading-6 text-foreground duration-300 ease-in-out hover:text-primary transition-colors':
|
||||
"text-sm font-normal leading-6 text-foreground duration-300 ease-in-out hover:text-primary transition-colors":
|
||||
!setMobileMenuOpen,
|
||||
});
|
||||
|
||||
@@ -202,7 +235,7 @@ function renderNavigationItems(
|
||||
to={item.to}
|
||||
className={menuStyles}
|
||||
onClick={setMobileMenuOpen && (() => setMobileMenuOpen(false))}
|
||||
target={item.to.startsWith('http') ? '_blank' : undefined}
|
||||
target={item.to.startsWith("http") ? "_blank" : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</ReactRouterLink>
|
||||
@@ -213,11 +246,11 @@ function renderNavigationItems(
|
||||
|
||||
const NavLogo = ({ isScrolled }: { isScrolled: boolean }) => (
|
||||
<img
|
||||
className={cn('transition-all duration-500', {
|
||||
'size-8': !isScrolled,
|
||||
'size-7': isScrolled,
|
||||
className={cn("transition-all duration-500", {
|
||||
"size-8": !isScrolled,
|
||||
"size-7": isScrolled,
|
||||
})}
|
||||
src={logo}
|
||||
alt='Your SaaS App'
|
||||
alt="Your SaaS App"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { routes } from 'wasp/client/router';
|
||||
import { BlogUrl, DocsUrl } from '../../../shared/common';
|
||||
import type { NavigationItem } from './NavBar';
|
||||
import { routes } from "wasp/client/router";
|
||||
import { BlogUrl, DocsUrl } from "../../../shared/common";
|
||||
import type { NavigationItem } from "./NavBar";
|
||||
|
||||
const staticNavigationItems: NavigationItem[] = [
|
||||
{ name: 'Documentation', to: DocsUrl },
|
||||
{ name: 'Blog', to: BlogUrl },
|
||||
{ name: "Documentation", to: DocsUrl },
|
||||
{ name: "Blog", to: BlogUrl },
|
||||
];
|
||||
|
||||
export const marketingNavigationItems: NavigationItem[] = [
|
||||
{ name: 'Features', to: '/#features' },
|
||||
{ name: 'Pricing', to: routes.PricingPageRoute.to },
|
||||
{ name: "Features", to: "/#features" },
|
||||
{ name: "Pricing", to: routes.PricingPageRoute.to },
|
||||
...staticNavigationItems,
|
||||
] as const;
|
||||
|
||||
export const demoNavigationitems: NavigationItem[] = [
|
||||
{ name: 'AI Scheduler', to: routes.DemoAppRoute.to },
|
||||
{ name: 'File Upload', to: routes.FileUploadRoute.to },
|
||||
{ name: 'Pricing', to: routes.PricingPageRoute.to },
|
||||
{ name: "AI Scheduler", to: routes.DemoAppRoute.to },
|
||||
{ name: "File Upload", to: routes.FileUploadRoute.to },
|
||||
{ name: "Pricing", to: routes.PricingPageRoute.to },
|
||||
...staticNavigationItems,
|
||||
] as const;
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useAuth } from 'wasp/client/auth';
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import { useAuth } from "wasp/client/auth";
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
|
||||
export function NotFoundPage() {
|
||||
const { data: user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-center min-h-screen'>
|
||||
<div className='text-center'>
|
||||
<h1 className='text-6xl font-bold mb-4'>404</h1>
|
||||
<p className='text-lg text-bodydark mb-8'>Oops! The page you're looking for doesn't exist.</p>
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-6xl font-bold">404</h1>
|
||||
<p className="text-bodydark mb-8 text-lg">
|
||||
Oops! The page you're looking for doesn't exist.
|
||||
</p>
|
||||
<WaspRouterLink
|
||||
to={user ? routes.DemoAppRoute.to : routes.LandingPageRoute.to}
|
||||
className='inline-block px-8 py-3 text-accent-foreground font-semibold bg-accent rounded-lg hover:bg-accent/90 transition duration-300'
|
||||
className="text-accent-foreground bg-accent hover:bg-accent/90 inline-block rounded-lg px-8 py-3 font-semibold transition duration-300"
|
||||
>
|
||||
Go Back Home
|
||||
</WaspRouterLink>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as CookieConsent from 'vanilla-cookieconsent';
|
||||
import 'vanilla-cookieconsent/dist/cookieconsent.css';
|
||||
import getConfig from './Config';
|
||||
import { useEffect } from "react";
|
||||
import * as CookieConsent from "vanilla-cookieconsent";
|
||||
import "vanilla-cookieconsent/dist/cookieconsent.css";
|
||||
import getConfig from "./Config";
|
||||
|
||||
/**
|
||||
* NOTE: if you do not want to use the cookie consent banner, you should
|
||||
@@ -13,7 +13,7 @@ const CookieConsentBanner = () => {
|
||||
CookieConsent.run(getConfig());
|
||||
}, []);
|
||||
|
||||
return <div id='cookieconsent'></div>;
|
||||
return <div id="cookieconsent"></div>;
|
||||
};
|
||||
|
||||
export default CookieConsentBanner;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CookieConsentConfig } from 'vanilla-cookieconsent';
|
||||
import type { CookieConsentConfig } from "vanilla-cookieconsent";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -10,26 +10,26 @@ const getConfig = () => {
|
||||
// See https://cookieconsent.orestbida.com/reference/configuration-reference.html for configuration options.
|
||||
const config: CookieConsentConfig = {
|
||||
// Default configuration for the modal.
|
||||
root: 'body',
|
||||
root: "body",
|
||||
autoShow: true,
|
||||
disablePageInteraction: false,
|
||||
hideFromBots: import.meta.env.PROD ? true : false, // Set this to false for dev/headless tests otherwise the modal will not be visible.
|
||||
mode: 'opt-in',
|
||||
mode: "opt-in",
|
||||
revision: 0,
|
||||
|
||||
// Default configuration for the cookie.
|
||||
cookie: {
|
||||
name: 'cc_cookie',
|
||||
name: "cc_cookie",
|
||||
domain: location.hostname,
|
||||
path: '/',
|
||||
sameSite: 'Lax',
|
||||
path: "/",
|
||||
sameSite: "Lax",
|
||||
expiresAfterDays: 365,
|
||||
},
|
||||
|
||||
guiOptions: {
|
||||
consentModal: {
|
||||
layout: 'box',
|
||||
position: 'bottom right',
|
||||
layout: "box",
|
||||
position: "bottom right",
|
||||
equalWeightButtons: true,
|
||||
flipButtons: false,
|
||||
},
|
||||
@@ -47,7 +47,7 @@ const getConfig = () => {
|
||||
name: /^_ga/, // regex: match all cookies starting with '_ga'
|
||||
},
|
||||
{
|
||||
name: '_gid', // string: exact cookie name
|
||||
name: "_gid", // string: exact cookie name
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -55,22 +55,23 @@ const getConfig = () => {
|
||||
// https://cookieconsent.orestbida.com/reference/configuration-reference.html#category-services
|
||||
services: {
|
||||
ga: {
|
||||
label: 'Google Analytics',
|
||||
label: "Google Analytics",
|
||||
onAccept: () => {
|
||||
try {
|
||||
const GA_ANALYTICS_ID = import.meta.env.REACT_APP_GOOGLE_ANALYTICS_ID;
|
||||
const GA_ANALYTICS_ID = import.meta.env
|
||||
.REACT_APP_GOOGLE_ANALYTICS_ID;
|
||||
if (!GA_ANALYTICS_ID.length) {
|
||||
throw new Error('Google Analytics ID is missing');
|
||||
throw new Error("Google Analytics ID is missing");
|
||||
}
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(..._args: unknown[]) {
|
||||
(window.dataLayer as Array<any>).push(arguments);
|
||||
}
|
||||
gtag('js', new Date());
|
||||
gtag('config', GA_ANALYTICS_ID);
|
||||
gtag("js", new Date());
|
||||
gtag("config", GA_ANALYTICS_ID);
|
||||
|
||||
// Adding the script tag dynamically to the DOM.
|
||||
const script = document.createElement('script');
|
||||
const script = document.createElement("script");
|
||||
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ANALYTICS_ID}`;
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
@@ -85,15 +86,15 @@ const getConfig = () => {
|
||||
},
|
||||
|
||||
language: {
|
||||
default: 'en',
|
||||
default: "en",
|
||||
translations: {
|
||||
en: {
|
||||
consentModal: {
|
||||
title: 'We use cookies',
|
||||
title: "We use cookies",
|
||||
description:
|
||||
'We use cookies primarily for analytics to enhance your experience. By accepting, you agree to our use of these cookies. You can manage your preferences or learn more about our cookie policy.',
|
||||
acceptAllBtn: 'Accept all',
|
||||
acceptNecessaryBtn: 'Reject all',
|
||||
"We use cookies primarily for analytics to enhance your experience. By accepting, you agree to our use of these cookies. You can manage your preferences or learn more about our cookie policy.",
|
||||
acceptAllBtn: "Accept all",
|
||||
acceptNecessaryBtn: "Reject all",
|
||||
// showPreferencesBtn: 'Manage Individual preferences', // (OPTIONAL) Activates the preferences modal
|
||||
// TODO: Add your own privacy policy and terms and conditions links below.
|
||||
footer: `
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import useLocalStorage from './useLocalStorage';
|
||||
import { useEffect } from "react";
|
||||
import useLocalStorage from "./useLocalStorage";
|
||||
|
||||
export default function useColorMode() {
|
||||
const [colorMode, setColorMode] = useLocalStorage('color-theme', 'light');
|
||||
const [colorMode, setColorMode] = useLocalStorage("color-theme", "light");
|
||||
|
||||
useEffect(() => {
|
||||
const className = 'dark';
|
||||
const className = "dark";
|
||||
const bodyClass = window.document.body.classList;
|
||||
|
||||
colorMode === 'dark'
|
||||
colorMode === "dark"
|
||||
? bodyClass.add(className)
|
||||
: bodyClass.remove(className);
|
||||
}, [colorMode]);
|
||||
|
||||
return [colorMode, setColorMode];
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export const useIsLandingPage = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return useMemo(() => {
|
||||
return location.pathname === '/';
|
||||
return location.pathname === "/";
|
||||
}, [location]);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type SetValue<T> = T | ((val: T) => T);
|
||||
|
||||
function useLocalStorage<T>(
|
||||
key: string,
|
||||
initialValue: T
|
||||
initialValue: T,
|
||||
): [T, (value: SetValue<T>) => void] {
|
||||
// State to store our value
|
||||
// Pass initial state function to useState so logic is only executed once
|
||||
@@ -26,7 +26,7 @@ function useLocalStorage<T>(
|
||||
try {
|
||||
// Allow value to be a function so we have same API as useState
|
||||
const valueToStore =
|
||||
typeof storedValue === 'function'
|
||||
typeof storedValue === "function"
|
||||
? storedValue(storedValue)
|
||||
: storedValue;
|
||||
// Save state
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
@@ -10,25 +10,29 @@ const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item ref={ref} className={cn('border-b', className)} {...props} />
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AccordionItem.displayName = 'AccordionItem';
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className='flex'>
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180',
|
||||
className
|
||||
"flex flex-1 items-center justify-between py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className='h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200' />
|
||||
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
@@ -40,10 +44,10 @@ const AccordionContent = React.forwardRef<
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className='overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down'
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-4 pt-0', className)}>{children}</div>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
@@ -1,44 +1,59 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role='alert' className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = 'Alert';
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5 ref={ref} className={cn('mb-1 font-medium leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||
)
|
||||
);
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
@@ -9,7 +9,10 @@ const Avatar = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -19,7 +22,11 @@ const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image ref={ref} className={cn('aspect-square h-full w-full', className)} {...props} />
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
@@ -29,7 +36,10 @@ const AvatarFallback = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
|
||||
className={cn(
|
||||
"bg-muted flex h-full w-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
@@ -38,10 +42,16 @@ export interface ButtonProps
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
}
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,59 +1,99 @@
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
import { cva, VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const cardVariants = cva('rounded-xl border shadow hover:shadow-lg transition-all duration-300 xur', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
accent: 'bg-card-accent text-card-accent-foreground hover:scale-[1.02]',
|
||||
faded: 'text-card-faded-foreground scale-95 opacity-50',
|
||||
bento: 'bg-card-subtle text-card-subtle-foreground hover:scale-[1.02] border-none shadow-none',
|
||||
const cardVariants = cva(
|
||||
"rounded-xl border shadow hover:shadow-lg transition-all duration-300 xur",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
accent: "bg-card-accent text-card-accent-foreground hover:scale-[1.02]",
|
||||
faded: "text-card-faded-foreground scale-95 opacity-50",
|
||||
bento:
|
||||
"bg-card-subtle text-card-subtle-foreground hover:scale-[1.02] border-none shadow-none",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof cardVariants> {}
|
||||
interface CardProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof cardVariants> {}
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant = 'default', ...props }, ref) => (
|
||||
<div ref={ref} className={cn(cardVariants({ variant, className }))} {...props} />
|
||||
)
|
||||
({ className, variant = "default", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(cardVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
@@ -11,13 +11,15 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className
|
||||
"border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground peer h-4 w-4 shrink-0 rounded-sm border shadow focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<Check className='h-4 w-4' />
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
@@ -25,17 +25,18 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className='ml-auto' />
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
@@ -44,13 +45,14 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
|
||||
className
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
@@ -61,9 +63,9 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
|
||||
className
|
||||
"bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -80,9 +82,9 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -96,21 +98,22 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className='h-4 w-4' />
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
@@ -119,14 +122,14 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className='h-2 w-2 fill-current' />
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -142,7 +145,11 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -154,16 +161,24 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />;
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from 'react-hook-form';
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Label } from './label';
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Label } from "./label";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
@@ -22,7 +22,9 @@ type FormFieldContextValue<
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
@@ -45,7 +47,7 @@ const useFormField = () => {
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
@@ -64,20 +66,23 @@ type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = "FormItem";
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
@@ -86,66 +91,87 @@ const FormLabel = React.forwardRef<
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label ref={ref} className={cn(error && 'text-destructive', className)} htmlFor={formItemId} {...props} />
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
FormLabel.displayName = "FormLabel";
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = 'FormControl';
|
||||
FormControl.displayName = "FormControl";
|
||||
|
||||
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('text-[0.8rem] text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-[0.8rem]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = "FormDescription";
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? "") : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message ?? '') : children;
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-[0.8rem] font-medium", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = "FormMessage";
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-[0.8rem] font-medium text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
);
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useFormField };
|
||||
export {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useFormField,
|
||||
};
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
@@ -9,11 +9,14 @@ const Progress = React.forwardRef<
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className='h-full w-full flex-1 bg-primary transition-all'
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
@@ -17,14 +17,14 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
"border-input ring-offset-background data-[placeholder]:text-muted-foreground focus:ring-ring flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className='h-4 w-4 opacity-50' />
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
@@ -36,10 +36,13 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className='h-4 w-4' />
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
@@ -50,26 +53,30 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className='h-4 w-4' />
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
@@ -77,9 +84,9 @@ const SelectContent = React.forwardRef<
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -96,7 +103,7 @@ const SelectLabel = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -109,14 +116,14 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className='absolute right-2 flex h-3.5 w-3.5 items-center justify-center'>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className='h-4 w-4' />
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
@@ -128,7 +135,11 @@ const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
@@ -19,8 +19,8 @@ const SheetOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
@@ -29,56 +29,76 @@ const SheetOverlay = React.forwardRef<
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: 'right',
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||
({ side = 'right', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
<SheetPrimitive.Close className='absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary'>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = 'SheetFooter';
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
@@ -86,7 +106,7 @@ const SheetTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-foreground', className)}
|
||||
className={cn("text-foreground text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -98,7 +118,7 @@ const SheetDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
@@ -9,15 +9,15 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
"focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
"bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[60px] w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-sm focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Task } from 'wasp/entities';
|
||||
import { type Task } from "wasp/entities";
|
||||
|
||||
import {
|
||||
createTask,
|
||||
@@ -7,34 +7,45 @@ import {
|
||||
getAllTasksByUser,
|
||||
updateTask,
|
||||
useQuery,
|
||||
} from 'wasp/client/operations';
|
||||
} from "wasp/client/operations";
|
||||
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||
import { Checkbox } from '../components/ui/checkbox';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { GeneratedSchedule, Task as ScheduleTask, TaskItem, TaskPriority } from './schedule';
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card";
|
||||
import { Checkbox } from "../components/ui/checkbox";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { cn } from "../lib/utils";
|
||||
import type {
|
||||
GeneratedSchedule,
|
||||
Task as ScheduleTask,
|
||||
TaskItem,
|
||||
TaskPriority,
|
||||
} from "./schedule";
|
||||
|
||||
export default function DemoAppPage() {
|
||||
return (
|
||||
<div className='py-10 lg:mt-10'>
|
||||
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
|
||||
<div className='mx-auto max-w-4xl text-center'>
|
||||
<h2 className='mt-2 text-4xl font-bold tracking-tight text-foreground sm:text-5xl'>
|
||||
<span className='text-primary'>AI</span> Day Scheduler
|
||||
<div className="py-10 lg:mt-10">
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<h2 className="text-foreground mt-2 text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
<span className="text-primary">AI</span> Day Scheduler
|
||||
</h2>
|
||||
</div>
|
||||
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-muted-foreground'>
|
||||
This example app uses OpenAI's chat completions with function calling to return a structured JSON
|
||||
object. Try it out, enter your day's tasks, and let AI do the rest!
|
||||
<p className="text-muted-foreground mx-auto mt-6 max-w-2xl text-center text-lg leading-8">
|
||||
This example app uses OpenAI's chat completions with function calling
|
||||
to return a structured JSON object. Try it out, enter your day's
|
||||
tasks, and let AI do the rest!
|
||||
</p>
|
||||
{/* begin AI-powered Todo List */}
|
||||
<Card className='my-8 bg-muted/10'>
|
||||
<CardContent className='sm:w-[90%] md:w-[70%] lg:w-[50%] py-10 px-6 mx-auto my-8 space-y-10'>
|
||||
<Card className="bg-muted/10 my-8">
|
||||
<CardContent className="mx-auto my-8 space-y-10 px-6 py-10 sm:w-[90%] md:w-[70%] lg:w-[50%]">
|
||||
<NewTaskForm handleCreateTask={createTask} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -44,82 +55,87 @@ export default function DemoAppPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask }) {
|
||||
const [description, setDescription] = useState<string>('');
|
||||
function NewTaskForm({
|
||||
handleCreateTask,
|
||||
}: {
|
||||
handleCreateTask: typeof createTask;
|
||||
}) {
|
||||
const [description, setDescription] = useState<string>("");
|
||||
const [todaysHours, setTodaysHours] = useState<number>(8);
|
||||
const [response, setResponse] = useState<GeneratedSchedule | null>({
|
||||
tasks: [
|
||||
{
|
||||
name: 'Respond to emails',
|
||||
priority: 'high' as TaskPriority,
|
||||
name: "Respond to emails",
|
||||
priority: "high" as TaskPriority,
|
||||
},
|
||||
{
|
||||
name: 'Learn WASP',
|
||||
priority: 'low' as TaskPriority,
|
||||
name: "Learn WASP",
|
||||
priority: "low" as TaskPriority,
|
||||
},
|
||||
{
|
||||
name: 'Read a book',
|
||||
priority: 'medium' as TaskPriority,
|
||||
name: "Read a book",
|
||||
priority: "medium" as TaskPriority,
|
||||
},
|
||||
],
|
||||
taskItems: [
|
||||
{
|
||||
description: 'Read introduction and chapter 1',
|
||||
description: "Read introduction and chapter 1",
|
||||
time: 0.5,
|
||||
taskName: 'Read a book',
|
||||
taskName: "Read a book",
|
||||
},
|
||||
{
|
||||
description: 'Read chapter 2 and take notes',
|
||||
description: "Read chapter 2 and take notes",
|
||||
time: 0.3,
|
||||
taskName: 'Read a book',
|
||||
taskName: "Read a book",
|
||||
},
|
||||
{
|
||||
description: 'Read chapter 3 and summarize key points',
|
||||
description: "Read chapter 3 and summarize key points",
|
||||
time: 0.2,
|
||||
taskName: 'Read a book',
|
||||
taskName: "Read a book",
|
||||
},
|
||||
{
|
||||
description: 'Check and respond to important emails',
|
||||
description: "Check and respond to important emails",
|
||||
time: 1,
|
||||
taskName: 'Respond to emails',
|
||||
taskName: "Respond to emails",
|
||||
},
|
||||
{
|
||||
description: 'Organize and prioritize remaining emails',
|
||||
description: "Organize and prioritize remaining emails",
|
||||
time: 0.5,
|
||||
taskName: 'Respond to emails',
|
||||
taskName: "Respond to emails",
|
||||
},
|
||||
{
|
||||
description: 'Draft responses to urgent emails',
|
||||
description: "Draft responses to urgent emails",
|
||||
time: 0.5,
|
||||
taskName: 'Respond to emails',
|
||||
taskName: "Respond to emails",
|
||||
},
|
||||
{
|
||||
description: 'Watch tutorial video on WASP',
|
||||
description: "Watch tutorial video on WASP",
|
||||
time: 0.5,
|
||||
taskName: 'Learn WASP',
|
||||
taskName: "Learn WASP",
|
||||
},
|
||||
{
|
||||
description: 'Complete online quiz on the basics of WASP',
|
||||
description: "Complete online quiz on the basics of WASP",
|
||||
time: 1.5,
|
||||
taskName: 'Learn WASP',
|
||||
taskName: "Learn WASP",
|
||||
},
|
||||
{
|
||||
description: 'Review quiz answers and clarify doubts',
|
||||
description: "Review quiz answers and clarify doubts",
|
||||
time: 1,
|
||||
taskName: 'Learn WASP',
|
||||
taskName: "Learn WASP",
|
||||
},
|
||||
],
|
||||
});
|
||||
const [isPlanGenerating, setIsPlanGenerating] = useState<boolean>(false);
|
||||
|
||||
const { data: tasks, isLoading: isTasksLoading } = useQuery(getAllTasksByUser);
|
||||
const { data: tasks, isLoading: isTasksLoading } =
|
||||
useQuery(getAllTasksByUser);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await handleCreateTask({ description });
|
||||
setDescription('');
|
||||
setDescription("");
|
||||
} catch (err: any) {
|
||||
window.alert('Error: ' + (err.message || 'Something went wrong'));
|
||||
window.alert("Error: " + (err.message || "Something went wrong"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,45 +149,47 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
|
||||
setResponse(response as unknown as GeneratedSchedule);
|
||||
}
|
||||
} catch (err: any) {
|
||||
window.alert('Error: ' + (err.message || 'Something went wrong'));
|
||||
window.alert("Error: " + (err.message || "Something went wrong"));
|
||||
} finally {
|
||||
setIsPlanGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col justify-center gap-10'>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className="flex flex-col justify-center gap-10">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Input
|
||||
type='text'
|
||||
id='description'
|
||||
className='flex-1'
|
||||
placeholder='Enter task description'
|
||||
type="text"
|
||||
id="description"
|
||||
className="flex-1"
|
||||
placeholder="Enter task description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!description}
|
||||
variant='default'
|
||||
size='default'
|
||||
variant="default"
|
||||
size="default"
|
||||
>
|
||||
Add Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-10 col-span-full'>
|
||||
{isTasksLoading && <div className='text-muted-foreground'>Loading...</div>}
|
||||
<div className="col-span-full space-y-10">
|
||||
{isTasksLoading && (
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
)}
|
||||
{tasks!! && tasks.length > 0 ? (
|
||||
<div className='space-y-4'>
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task: Task) => (
|
||||
<Todo
|
||||
key={task.id}
|
||||
@@ -181,18 +199,21 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
|
||||
time={task.time}
|
||||
/>
|
||||
))}
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Label htmlFor='time' className='text-sm text-muted-foreground text-nowrap font-semibold'>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label
|
||||
htmlFor="time"
|
||||
className="text-muted-foreground text-nowrap text-sm font-semibold"
|
||||
>
|
||||
How many hours will you work today?
|
||||
</Label>
|
||||
<Input
|
||||
type='number'
|
||||
id='time'
|
||||
type="number"
|
||||
id="time"
|
||||
step={0.5}
|
||||
min={1}
|
||||
max={24}
|
||||
className='min-w-[7rem] text-center'
|
||||
className="min-w-[7rem] text-center"
|
||||
value={todaysHours}
|
||||
onChange={(e) => setTodaysHours(+e.currentTarget.value)}
|
||||
/>
|
||||
@@ -200,32 +221,36 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground text-center'>Add tasks to begin</div>
|
||||
<div className="text-muted-foreground text-center">
|
||||
Add tasks to begin
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
type="button"
|
||||
disabled={isPlanGenerating || tasks?.length === 0}
|
||||
onClick={() => handleGeneratePlan()}
|
||||
variant='default'
|
||||
size='default'
|
||||
className='w-full'
|
||||
data-testid='generate-schedule-button'
|
||||
variant="default"
|
||||
size="default"
|
||||
className="w-full"
|
||||
data-testid="generate-schedule-button"
|
||||
>
|
||||
{isPlanGenerating ? (
|
||||
<>
|
||||
<Loader2 className='inline-block mr-2 animate-spin' />
|
||||
<Loader2 className="mr-2 inline-block animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
'Generate Schedule'
|
||||
"Generate Schedule"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!!response && (
|
||||
<div className='flex flex-col'>
|
||||
<h3 className='text-lg font-semibold text-foreground mb-4'>Today's Schedule</h3>
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-foreground mb-4 text-lg font-semibold">
|
||||
Today's Schedule
|
||||
</h3>
|
||||
<Schedule schedule={response} />
|
||||
</div>
|
||||
)}
|
||||
@@ -233,7 +258,7 @@ function NewTaskForm({ handleCreateTask }: { handleCreateTask: typeof createTask
|
||||
);
|
||||
}
|
||||
|
||||
type TodoProps = Pick<Task, 'id' | 'isDone' | 'description' | 'time'>;
|
||||
type TodoProps = Pick<Task, "id" | "isDone" | "description" | "time">;
|
||||
|
||||
function Todo({ id, isDone, description, time }: TodoProps) {
|
||||
const handleCheckboxChange = async (checked: boolean) => {
|
||||
@@ -255,53 +280,53 @@ function Todo({ id, isDone, description, time }: TodoProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='p-4'>
|
||||
<div className='flex items-center justify-between w-full'>
|
||||
<div className='flex items-center justify-between gap-5 w-full'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Card className="p-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex w-full items-center justify-between gap-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isDone}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
className='data-[state=checked]:bg-primary data-[state=checked]:border-primary'
|
||||
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<span
|
||||
className={cn('text-foreground', {
|
||||
'line-through text-muted-foreground': isDone,
|
||||
className={cn("text-foreground", {
|
||||
"text-muted-foreground line-through": isDone,
|
||||
})}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id='time'
|
||||
type='number'
|
||||
id="time"
|
||||
type="number"
|
||||
min={0.5}
|
||||
step={0.5}
|
||||
className={cn('w-18 h-8 text-center text-xs', {
|
||||
'pointer-events-none opacity-50': isDone,
|
||||
className={cn("w-18 h-8 text-center text-xs", {
|
||||
"pointer-events-none opacity-50": isDone,
|
||||
})}
|
||||
value={time}
|
||||
onChange={handleTimeChange}
|
||||
/>
|
||||
<span
|
||||
className={cn('italic text-muted-foreground text-xs', {
|
||||
'text-muted-foreground': isDone,
|
||||
className={cn("text-muted-foreground text-xs italic", {
|
||||
"text-muted-foreground": isDone,
|
||||
})}
|
||||
>
|
||||
hrs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center justify-end w-15'>
|
||||
<div className="w-15 flex items-center justify-end">
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDeleteClick}
|
||||
title='Remove task'
|
||||
className='p-1 h-auto text-destructive hover:text-destructive/80'
|
||||
title="Remove task"
|
||||
className="text-destructive hover:text-destructive/80 h-auto p-1"
|
||||
>
|
||||
<Trash2 size='20' />
|
||||
<Trash2 size="20" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -311,56 +336,76 @@ function Todo({ id, isDone, description, time }: TodoProps) {
|
||||
|
||||
function Schedule({ schedule }: { schedule: GeneratedSchedule }) {
|
||||
return (
|
||||
<div className='flex flex-col gap-6 py-6' data-testid='schedule'>
|
||||
<div className='space-y-4'>
|
||||
<div className="flex flex-col gap-6 py-6" data-testid="schedule">
|
||||
<div className="space-y-4">
|
||||
{!!schedule.tasks ? (
|
||||
schedule.tasks
|
||||
.map((task) => <TaskCard key={task.name} task={task} taskItems={schedule.taskItems} />)
|
||||
.map((task) => (
|
||||
<TaskCard
|
||||
key={task.name}
|
||||
task={task}
|
||||
taskItems={schedule.taskItems}
|
||||
/>
|
||||
))
|
||||
.sort((a, b) => {
|
||||
const priorityOrder: TaskPriority[] = ['low', 'medium', 'high'];
|
||||
const priorityOrder: TaskPriority[] = ["low", "medium", "high"];
|
||||
if (a.props.task.priority && b.props.task.priority) {
|
||||
return (
|
||||
priorityOrder.indexOf(b.props.task.priority) - priorityOrder.indexOf(a.props.task.priority)
|
||||
priorityOrder.indexOf(b.props.task.priority) -
|
||||
priorityOrder.indexOf(a.props.task.priority)
|
||||
);
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
) : (
|
||||
<div className='text-muted-foreground text-center'>OpenAI didn't return any Tasks. Try again.</div>
|
||||
<div className="text-muted-foreground text-center">
|
||||
OpenAI didn't return any Tasks. Try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskCard({ task, taskItems }: { task: ScheduleTask; taskItems: TaskItem[] }) {
|
||||
function TaskCard({
|
||||
task,
|
||||
taskItems,
|
||||
}: {
|
||||
task: ScheduleTask;
|
||||
taskItems: TaskItem[];
|
||||
}) {
|
||||
const taskPriorityToColorMap: Record<TaskPriority, string> = {
|
||||
high: 'bg-destructive/10 border-destructive/20 text-red-500',
|
||||
medium: 'bg-warning/10 border-warning/20 text-warning',
|
||||
low: 'bg-success/10 border-success/20 text-success',
|
||||
high: "bg-destructive/10 border-destructive/20 text-red-500",
|
||||
medium: "bg-warning/10 border-warning/20 text-warning",
|
||||
low: "bg-success/10 border-success/20 text-success",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('border-2', taskPriorityToColorMap[task.priority])}>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='flex items-center justify-between text-base'>
|
||||
<Card className={cn("border-2", taskPriorityToColorMap[task.priority])}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>{task.name}</span>
|
||||
<span className='text-xs font-medium italic'> {task.priority} priority</span>
|
||||
<span className="text-xs font-medium italic">
|
||||
{" "}
|
||||
{task.priority} priority
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<CardContent className="pt-0">
|
||||
{!!taskItems ? (
|
||||
<ul className='space-y-2'>
|
||||
<ul className="space-y-2">
|
||||
{taskItems.map((taskItem) => {
|
||||
if (taskItem.taskName === task.name) {
|
||||
return <TaskCardItem key={taskItem.description} {...taskItem} />;
|
||||
return (
|
||||
<TaskCardItem key={taskItem.description} {...taskItem} />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className='text-muted-foreground text-center'>
|
||||
<div className="text-muted-foreground text-center">
|
||||
OpenAI didn't return any Task Items. Try again.
|
||||
</div>
|
||||
)}
|
||||
@@ -373,7 +418,7 @@ function TaskCardItem({ description, time }: TaskItem) {
|
||||
const [isDone, setIsDone] = useState<boolean>(false);
|
||||
|
||||
const formattedTime = useMemo(() => {
|
||||
if (time === 0) return '0min';
|
||||
if (time === 0) return "0min";
|
||||
const hours = Math.floor(time);
|
||||
const minutes = Math.round((time - hours) * 60);
|
||||
|
||||
@@ -381,32 +426,32 @@ function TaskCardItem({ description, time }: TaskItem) {
|
||||
if (hours > 0) parts.push(`${hours}hr`);
|
||||
if (minutes > 0) parts.push(`${minutes}min`);
|
||||
|
||||
return parts.join(' ');
|
||||
return parts.join(" ");
|
||||
}, [time]);
|
||||
|
||||
const handleCheckedChange = (checked: boolean | 'indeterminate') => {
|
||||
const handleCheckedChange = (checked: boolean | "indeterminate") => {
|
||||
setIsDone(checked === true);
|
||||
};
|
||||
|
||||
return (
|
||||
<li className='flex items-center justify-between gap-4 p-2 rounded-md'>
|
||||
<div className='flex items-center gap-3 flex-1'>
|
||||
<li className="flex items-center justify-between gap-4 rounded-md p-2">
|
||||
<div className="flex flex-1 items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isDone}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className='data-[state=checked]:bg-primary data-[state=checked]:border-primary'
|
||||
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<span
|
||||
className={cn('leading-tight text-sm', {
|
||||
'line-through text-muted-foreground opacity-50': isDone,
|
||||
className={cn("text-sm leading-tight", {
|
||||
"text-muted-foreground line-through opacity-50": isDone,
|
||||
})}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn('text-sm text-muted-foreground', {
|
||||
'line-through opacity-50': isDone,
|
||||
className={cn("text-muted-foreground text-sm", {
|
||||
"line-through opacity-50": isDone,
|
||||
})}
|
||||
>
|
||||
{formattedTime}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PrismaPromise } from '@prisma/client';
|
||||
import OpenAI from 'openai';
|
||||
import type { GptResponse, Task, User } from 'wasp/entities';
|
||||
import { HttpError, prisma } from 'wasp/server';
|
||||
import type { PrismaPromise } from "@prisma/client";
|
||||
import OpenAI from "openai";
|
||||
import type { GptResponse, Task, User } from "wasp/entities";
|
||||
import { HttpError, prisma } from "wasp/server";
|
||||
import type {
|
||||
CreateTask,
|
||||
DeleteTask,
|
||||
@@ -9,18 +9,18 @@ import type {
|
||||
GetAllTasksByUser,
|
||||
GetGptResponses,
|
||||
UpdateTask,
|
||||
} from 'wasp/server/operations';
|
||||
import * as z from 'zod';
|
||||
import { SubscriptionStatus } from '../payment/plans';
|
||||
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
|
||||
import { GeneratedSchedule, TaskPriority } from './schedule';
|
||||
} from "wasp/server/operations";
|
||||
import * as z from "zod";
|
||||
import { SubscriptionStatus } from "../payment/plans";
|
||||
import { ensureArgsSchemaOrThrowHttpError } from "../server/validation";
|
||||
import { GeneratedSchedule, TaskPriority } from "./schedule";
|
||||
|
||||
const openAi = setUpOpenAi();
|
||||
function setUpOpenAi(): OpenAI {
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
} else {
|
||||
throw new Error('OpenAI API key is not set');
|
||||
throw new Error("OpenAI API key is not set");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,15 +31,21 @@ const generateGptResponseInputSchema = z.object({
|
||||
|
||||
type GenerateGptResponseInput = z.infer<typeof generateGptResponseInputSchema>;
|
||||
|
||||
export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput, GeneratedSchedule> = async (
|
||||
rawArgs,
|
||||
context
|
||||
) => {
|
||||
export const generateGptResponse: GenerateGptResponse<
|
||||
GenerateGptResponseInput,
|
||||
GeneratedSchedule
|
||||
> = async (rawArgs, context) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401, 'Only authenticated users are allowed to perform this operation');
|
||||
throw new HttpError(
|
||||
401,
|
||||
"Only authenticated users are allowed to perform this operation",
|
||||
);
|
||||
}
|
||||
|
||||
const { hours } = ensureArgsSchemaOrThrowHttpError(generateGptResponseInputSchema, rawArgs);
|
||||
const { hours } = ensureArgsSchemaOrThrowHttpError(
|
||||
generateGptResponseInputSchema,
|
||||
rawArgs,
|
||||
);
|
||||
const tasks = await context.entities.Task.findMany({
|
||||
where: {
|
||||
user: {
|
||||
@@ -48,10 +54,13 @@ export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Calling open AI api');
|
||||
console.log("Calling open AI api");
|
||||
const generatedSchedule = await generateScheduleWithGpt(tasks, hours);
|
||||
if (generatedSchedule === null) {
|
||||
throw new HttpError(500, 'Encountered a problem in communication with OpenAI');
|
||||
throw new HttpError(
|
||||
500,
|
||||
"Encountered a problem in communication with OpenAI",
|
||||
);
|
||||
}
|
||||
|
||||
const createResponse = context.entities.GptResponse.create({
|
||||
@@ -83,11 +92,11 @@ export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput,
|
||||
});
|
||||
transactions.push(decrementCredit);
|
||||
} else {
|
||||
throw new HttpError(402, 'User has not paid or is out of credits');
|
||||
throw new HttpError(402, "User has not paid or is out of credits");
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Decrementing credits and saving response');
|
||||
console.log("Decrementing credits and saving response");
|
||||
await prisma.$transaction(transactions);
|
||||
|
||||
return generatedSchedule;
|
||||
@@ -106,12 +115,18 @@ const createTaskInputSchema = z.object({
|
||||
|
||||
type CreateTaskInput = z.infer<typeof createTaskInputSchema>;
|
||||
|
||||
export const createTask: CreateTask<CreateTaskInput, Task> = async (rawArgs, context) => {
|
||||
export const createTask: CreateTask<CreateTaskInput, Task> = async (
|
||||
rawArgs,
|
||||
context,
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
}
|
||||
|
||||
const { description } = ensureArgsSchemaOrThrowHttpError(createTaskInputSchema, rawArgs);
|
||||
const { description } = ensureArgsSchemaOrThrowHttpError(
|
||||
createTaskInputSchema,
|
||||
rawArgs,
|
||||
);
|
||||
|
||||
const task = await context.entities.Task.create({
|
||||
data: {
|
||||
@@ -131,12 +146,18 @@ const updateTaskInputSchema = z.object({
|
||||
|
||||
type UpdateTaskInput = z.infer<typeof updateTaskInputSchema>;
|
||||
|
||||
export const updateTask: UpdateTask<UpdateTaskInput, Task> = async (rawArgs, context) => {
|
||||
export const updateTask: UpdateTask<UpdateTaskInput, Task> = async (
|
||||
rawArgs,
|
||||
context,
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
}
|
||||
|
||||
const { id, isDone, time } = ensureArgsSchemaOrThrowHttpError(updateTaskInputSchema, rawArgs);
|
||||
const { id, isDone, time } = ensureArgsSchemaOrThrowHttpError(
|
||||
updateTaskInputSchema,
|
||||
rawArgs,
|
||||
);
|
||||
|
||||
const task = await context.entities.Task.update({
|
||||
where: {
|
||||
@@ -160,12 +181,18 @@ const deleteTaskInputSchema = z.object({
|
||||
|
||||
type DeleteTaskInput = z.infer<typeof deleteTaskInputSchema>;
|
||||
|
||||
export const deleteTask: DeleteTask<DeleteTaskInput, Task> = async (rawArgs, context) => {
|
||||
export const deleteTask: DeleteTask<DeleteTaskInput, Task> = async (
|
||||
rawArgs,
|
||||
context,
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
}
|
||||
|
||||
const { id } = ensureArgsSchemaOrThrowHttpError(deleteTaskInputSchema, rawArgs);
|
||||
const { id } = ensureArgsSchemaOrThrowHttpError(
|
||||
deleteTaskInputSchema,
|
||||
rawArgs,
|
||||
);
|
||||
|
||||
const task = await context.entities.Task.delete({
|
||||
where: {
|
||||
@@ -181,7 +208,10 @@ export const deleteTask: DeleteTask<DeleteTaskInput, Task> = async (rawArgs, con
|
||||
//#endregion
|
||||
|
||||
//#region Queries
|
||||
export const getGptResponses: GetGptResponses<void, GptResponse[]> = async (_args, context) => {
|
||||
export const getGptResponses: GetGptResponses<void, GptResponse[]> = async (
|
||||
_args,
|
||||
context,
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
}
|
||||
@@ -194,7 +224,10 @@ export const getGptResponses: GetGptResponses<void, GptResponse[]> = async (_arg
|
||||
});
|
||||
};
|
||||
|
||||
export const getAllTasksByUser: GetAllTasksByUser<void, Task[]> = async (_args, context) => {
|
||||
export const getAllTasksByUser: GetAllTasksByUser<void, Task[]> = async (
|
||||
_args,
|
||||
context,
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
}
|
||||
@@ -205,96 +238,102 @@ export const getAllTasksByUser: GetAllTasksByUser<void, Task[]> = async (_args,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
};
|
||||
//#endregion
|
||||
|
||||
async function generateScheduleWithGpt(tasks: Task[], hours: number): Promise<GeneratedSchedule | null> {
|
||||
async function generateScheduleWithGpt(
|
||||
tasks: Task[],
|
||||
hours: number,
|
||||
): Promise<GeneratedSchedule | null> {
|
||||
const parsedTasks = tasks.map(({ description, time }) => ({
|
||||
description,
|
||||
time,
|
||||
}));
|
||||
|
||||
const completion = await openAi.chat.completions.create({
|
||||
model: 'gpt-3.5-turbo', // you can use any model here, e.g. 'gpt-3.5-turbo', 'gpt-4', etc.
|
||||
model: "gpt-3.5-turbo", // you can use any model here, e.g. 'gpt-3.5-turbo', 'gpt-4', etc.
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
role: "system",
|
||||
content:
|
||||
'you are an expert daily planner. you will be given a list of main tasks and an estimated time to complete each task. You will also receive the total amount of hours to be worked that day. Your job is to return a detailed plan of how to achieve those tasks by breaking each task down into at least 3 subtasks each. MAKE SURE TO ALWAYS CREATE AT LEAST 3 SUBTASKS FOR EACH MAIN TASK PROVIDED BY THE USER! YOU WILL BE REWARDED IF YOU DO.',
|
||||
"you are an expert daily planner. you will be given a list of main tasks and an estimated time to complete each task. You will also receive the total amount of hours to be worked that day. Your job is to return a detailed plan of how to achieve those tasks by breaking each task down into at least 3 subtasks each. MAKE SURE TO ALWAYS CREATE AT LEAST 3 SUBTASKS FOR EACH MAIN TASK PROVIDED BY THE USER! YOU WILL BE REWARDED IF YOU DO.",
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
role: "user",
|
||||
content: `I will work ${hours} hours today. Here are the tasks I have to complete: ${JSON.stringify(
|
||||
parsedTasks
|
||||
parsedTasks,
|
||||
)}. Please help me plan my day by breaking the tasks down into actionable subtasks with time and priority status.`,
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
type: "function",
|
||||
function: {
|
||||
name: 'parseTodaysSchedule',
|
||||
description: 'parses the days tasks and returns a schedule',
|
||||
name: "parseTodaysSchedule",
|
||||
description: "parses the days tasks and returns a schedule",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
type: "object",
|
||||
properties: {
|
||||
tasks: {
|
||||
type: 'array',
|
||||
description: 'Name of main tasks provided by user, ordered by priority',
|
||||
type: "array",
|
||||
description:
|
||||
"Name of main tasks provided by user, ordered by priority",
|
||||
items: {
|
||||
type: 'object',
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Name of main task provided by user',
|
||||
type: "string",
|
||||
description: "Name of main task provided by user",
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
enum: ['low', 'medium', 'high'] as TaskPriority[],
|
||||
description: 'task priority',
|
||||
type: "string",
|
||||
enum: ["low", "medium", "high"] as TaskPriority[],
|
||||
description: "task priority",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
taskItems: {
|
||||
type: 'array',
|
||||
type: "array",
|
||||
items: {
|
||||
type: 'object',
|
||||
type: "object",
|
||||
properties: {
|
||||
description: {
|
||||
type: 'string',
|
||||
type: "string",
|
||||
description:
|
||||
'detailed breakdown and description of sub-task related to main task. e.g., "Prepare your learning session by first reading through the documentation"',
|
||||
},
|
||||
time: {
|
||||
type: 'number',
|
||||
description: 'time allocated for a given subtask in hours, e.g. 0.5',
|
||||
type: "number",
|
||||
description:
|
||||
"time allocated for a given subtask in hours, e.g. 0.5",
|
||||
},
|
||||
taskName: {
|
||||
type: 'string',
|
||||
description: 'name of main task related to subtask',
|
||||
type: "string",
|
||||
description: "name of main task related to subtask",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['tasks', 'taskItems', 'time', 'priority'],
|
||||
required: ["tasks", "taskItems", "time", "priority"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: {
|
||||
type: 'function',
|
||||
type: "function",
|
||||
function: {
|
||||
name: 'parseTodaysSchedule',
|
||||
name: "parseTodaysSchedule",
|
||||
},
|
||||
},
|
||||
temperature: 1,
|
||||
});
|
||||
|
||||
const gptResponse = completion?.choices[0]?.message?.tool_calls?.[0]?.function.arguments;
|
||||
const gptResponse =
|
||||
completion?.choices[0]?.message?.tool_calls?.[0]?.function.arguments;
|
||||
return gptResponse !== undefined ? JSON.parse(gptResponse) : null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type TaskPriority = 'low' | 'medium' | 'high';
|
||||
export type TaskPriority = "low" | "medium" | "high";
|
||||
|
||||
export type GeneratedSchedule = {
|
||||
tasks: Task[]; // Main tasks provided by user, ordered by priority
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { getAllFilesByUser, getDownloadFileSignedURL, useQuery } from 'wasp/client/operations';
|
||||
import type { File } from 'wasp/entities';
|
||||
import { Alert, AlertDescription } from '../components/ui/alert';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card, CardContent, CardTitle } from '../components/ui/card';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Progress } from '../components/ui/progress';
|
||||
import { cn } from '../lib/utils';
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import {
|
||||
getAllFilesByUser,
|
||||
getDownloadFileSignedURL,
|
||||
useQuery,
|
||||
} from "wasp/client/operations";
|
||||
import type { File } from "wasp/entities";
|
||||
import { Alert, AlertDescription } from "../components/ui/alert";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardTitle } from "../components/ui/card";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Progress } from "../components/ui/progress";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
type FileUploadError,
|
||||
type FileWithValidType,
|
||||
uploadFileWithProgress,
|
||||
validateFile,
|
||||
} from './fileUploading';
|
||||
import { ALLOWED_FILE_TYPES } from './validation';
|
||||
} from "./fileUploading";
|
||||
import { ALLOWED_FILE_TYPES } from "./validation";
|
||||
|
||||
export default function FileUploadPage() {
|
||||
const [fileKeyForS3, setFileKeyForS3] = useState<File['key']>('');
|
||||
const [fileKeyForS3, setFileKeyForS3] = useState<File["key"]>("");
|
||||
const [uploadProgressPercent, setUploadProgressPercent] = useState<number>(0);
|
||||
const [uploadError, setUploadError] = useState<FileUploadError | null>(null);
|
||||
|
||||
@@ -26,11 +30,12 @@ export default function FileUploadPage() {
|
||||
// which happens before the file is actually fully uploaded. Instead, we manually (re)fetch on mount and after the upload is complete.
|
||||
enabled: false,
|
||||
});
|
||||
const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } = useQuery(
|
||||
getDownloadFileSignedURL,
|
||||
{ key: fileKeyForS3 },
|
||||
{ enabled: false }
|
||||
);
|
||||
const { isLoading: isDownloadUrlLoading, refetch: refetchDownloadUrl } =
|
||||
useQuery(
|
||||
getDownloadFileSignedURL,
|
||||
{ key: fileKeyForS3 },
|
||||
{ enabled: false },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
allUserFiles.refetch();
|
||||
@@ -41,17 +46,17 @@ export default function FileUploadPage() {
|
||||
refetchDownloadUrl()
|
||||
.then((urlQuery) => {
|
||||
switch (urlQuery.status) {
|
||||
case 'error':
|
||||
console.error('Error fetching download URL', urlQuery.error);
|
||||
alert('Error fetching download');
|
||||
case "error":
|
||||
console.error("Error fetching download URL", urlQuery.error);
|
||||
alert("Error fetching download");
|
||||
return;
|
||||
case 'success':
|
||||
window.open(urlQuery.data, '_blank');
|
||||
case "success":
|
||||
window.open(urlQuery.data, "_blank");
|
||||
return;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setFileKeyForS3('');
|
||||
setFileKeyForS3("");
|
||||
});
|
||||
}
|
||||
}, [fileKeyForS3]);
|
||||
@@ -62,16 +67,16 @@ export default function FileUploadPage() {
|
||||
|
||||
const formElement = e.target;
|
||||
if (!(formElement instanceof HTMLFormElement)) {
|
||||
throw new Error('Event target is not a form element');
|
||||
throw new Error("Event target is not a form element");
|
||||
}
|
||||
|
||||
const formData = new FormData(formElement);
|
||||
const file = formData.get('file-upload');
|
||||
const file = formData.get("file-upload");
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
setUploadError({
|
||||
message: 'Please select a file to upload.',
|
||||
code: 'NO_FILE',
|
||||
message: "Please select a file to upload.",
|
||||
code: "NO_FILE",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -82,15 +87,20 @@ export default function FileUploadPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
await uploadFileWithProgress({ file: file as FileWithValidType, setUploadProgressPercent });
|
||||
await uploadFileWithProgress({
|
||||
file: file as FileWithValidType,
|
||||
setUploadProgressPercent,
|
||||
});
|
||||
formElement.reset();
|
||||
allUserFiles.refetch();
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
console.error("Error uploading file:", error);
|
||||
setUploadError({
|
||||
message:
|
||||
error instanceof Error ? error.message : 'An unexpected error occurred while uploading the file.',
|
||||
code: 'UPLOAD_FAILED',
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred while uploading the file.",
|
||||
code: "UPLOAD_FAILED",
|
||||
});
|
||||
} finally {
|
||||
setUploadProgressPercent(0);
|
||||
@@ -98,81 +108,110 @@ export default function FileUploadPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='py-10 lg:mt-10'>
|
||||
<div className='mx-auto max-w-7xl px-6 lg:px-8'>
|
||||
<div className='mx-auto max-w-4xl text-center'>
|
||||
<h2 className='mt-2 text-4xl font-bold tracking-tight text-foreground sm:text-5xl'>
|
||||
<span className='text-primary'>AWS</span> File Upload
|
||||
<div className="py-10 lg:mt-10">
|
||||
<div className="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<h2 className="text-foreground mt-2 text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
<span className="text-primary">AWS</span> File Upload
|
||||
</h2>
|
||||
</div>
|
||||
<p className='mx-auto mt-6 max-w-2xl text-center text-lg leading-8 text-muted-foreground'>
|
||||
This is an example file upload page using AWS S3. Maybe your app needs this. Maybe it doesn't. But a
|
||||
lot of people asked for this feature, so here you go 🤝
|
||||
<p className="text-muted-foreground mx-auto mt-6 max-w-2xl text-center text-lg leading-8">
|
||||
This is an example file upload page using AWS S3. Maybe your app needs
|
||||
this. Maybe it doesn't. But a lot of people asked for this feature, so
|
||||
here you go 🤝
|
||||
</p>
|
||||
<Card className='my-8'>
|
||||
<CardContent className='space-y-10 my-10 py-8 px-4 mx-auto sm:max-w-lg'>
|
||||
<form onSubmit={handleUpload} className='flex flex-col gap-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='file-upload' className='text-sm font-medium text-foreground'>
|
||||
<Card className="my-8">
|
||||
<CardContent className="mx-auto my-10 space-y-10 px-4 py-8 sm:max-w-lg">
|
||||
<form onSubmit={handleUpload} className="flex flex-col gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="file-upload"
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
Select a file to upload
|
||||
</Label>
|
||||
<Input
|
||||
type='file'
|
||||
id='file-upload'
|
||||
name='file-upload'
|
||||
accept={ALLOWED_FILE_TYPES.join(',')}
|
||||
type="file"
|
||||
id="file-upload"
|
||||
name="file-upload"
|
||||
accept={ALLOWED_FILE_TYPES.join(",")}
|
||||
onChange={() => setUploadError(null)}
|
||||
className='cursor-pointer'
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Button type='submit' disabled={uploadProgressPercent > 0} className='w-full'>
|
||||
{uploadProgressPercent > 0 ? `Uploading ${uploadProgressPercent}%` : 'Upload'}
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={uploadProgressPercent > 0}
|
||||
className="w-full"
|
||||
>
|
||||
{uploadProgressPercent > 0
|
||||
? `Uploading ${uploadProgressPercent}%`
|
||||
: "Upload"}
|
||||
</Button>
|
||||
{uploadProgressPercent > 0 && <Progress value={uploadProgressPercent} className='w-full' />}
|
||||
{uploadProgressPercent > 0 && (
|
||||
<Progress value={uploadProgressPercent} className="w-full" />
|
||||
)}
|
||||
</div>
|
||||
{uploadError && (
|
||||
<Alert variant='destructive'>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{uploadError.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</form>
|
||||
<div className='border-b-2 border-border'></div>
|
||||
<div className='space-y-4 col-span-full'>
|
||||
<CardTitle className='text-xl font-bold text-foreground'>Uploaded Files</CardTitle>
|
||||
{allUserFiles.isLoading && <p className='text-muted-foreground'>Loading...</p>}
|
||||
<div className="border-border border-b-2"></div>
|
||||
<div className="col-span-full space-y-4">
|
||||
<CardTitle className="text-foreground text-xl font-bold">
|
||||
Uploaded Files
|
||||
</CardTitle>
|
||||
{allUserFiles.isLoading && (
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
)}
|
||||
{allUserFiles.error && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>Error: {allUserFiles.error.message}</AlertDescription>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Error: {allUserFiles.error.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!!allUserFiles.data && allUserFiles.data.length > 0 && !allUserFiles.isLoading ? (
|
||||
<div className='space-y-3'>
|
||||
{!!allUserFiles.data &&
|
||||
allUserFiles.data.length > 0 &&
|
||||
!allUserFiles.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{allUserFiles.data.map((file: File) => (
|
||||
<Card key={file.key} className='p-4'>
|
||||
<Card key={file.key} className="p-4">
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3',
|
||||
"flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center",
|
||||
{
|
||||
'opacity-70': file.key === fileKeyForS3 && isDownloadUrlLoading,
|
||||
}
|
||||
"opacity-70":
|
||||
file.key === fileKeyForS3 && isDownloadUrlLoading,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<p className='text-foreground font-medium'>{file.name}</p>
|
||||
<p className="text-foreground font-medium">
|
||||
{file.name}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setFileKeyForS3(file.key)}
|
||||
disabled={file.key === fileKeyForS3 && isDownloadUrlLoading}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={
|
||||
file.key === fileKeyForS3 && isDownloadUrlLoading
|
||||
}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{file.key === fileKeyForS3 && isDownloadUrlLoading ? 'Loading...' : 'Download'}
|
||||
{file.key === fileKeyForS3 && isDownloadUrlLoading
|
||||
? "Loading..."
|
||||
: "Download"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-center'>No files uploaded yet :(</p>
|
||||
<p className="text-muted-foreground text-center">
|
||||
No files uploaded yet :(
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,55 +1,66 @@
|
||||
import { createFile } from 'wasp/client/operations';
|
||||
import axios from 'axios';
|
||||
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from './validation';
|
||||
import axios from "axios";
|
||||
import { createFile } from "wasp/client/operations";
|
||||
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE_BYTES } from "./validation";
|
||||
|
||||
export type FileWithValidType = Omit<File, 'type'> & { type: AllowedFileType };
|
||||
export type FileWithValidType = Omit<File, "type"> & { type: AllowedFileType };
|
||||
type AllowedFileType = (typeof ALLOWED_FILE_TYPES)[number];
|
||||
interface FileUploadProgress {
|
||||
file: FileWithValidType;
|
||||
setUploadProgressPercent: (percentage: number) => void;
|
||||
}
|
||||
|
||||
export async function uploadFileWithProgress({ file, setUploadProgressPercent }: FileUploadProgress) {
|
||||
const { s3UploadUrl, s3UploadFields } = await createFile({ fileType: file.type, fileName: file.name });
|
||||
export async function uploadFileWithProgress({
|
||||
file,
|
||||
setUploadProgressPercent,
|
||||
}: FileUploadProgress) {
|
||||
const { s3UploadUrl, s3UploadFields } = await createFile({
|
||||
fileType: file.type,
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
const formData = getFileUploadFormData(file, s3UploadFields);
|
||||
|
||||
return axios.post(s3UploadUrl, formData, {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
const percentage = Math.round((progressEvent.loaded / progressEvent.total) * 100);
|
||||
const percentage = Math.round(
|
||||
(progressEvent.loaded / progressEvent.total) * 100,
|
||||
);
|
||||
setUploadProgressPercent(percentage);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getFileUploadFormData(file: File, s3UploadFields: Record<string, string>) {
|
||||
function getFileUploadFormData(
|
||||
file: File,
|
||||
s3UploadFields: Record<string, string>,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
Object.entries(s3UploadFields).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
formData.append('file', file);
|
||||
formData.append("file", file);
|
||||
return formData;
|
||||
}
|
||||
|
||||
export interface FileUploadError {
|
||||
message: string;
|
||||
code: 'NO_FILE' | 'INVALID_FILE_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED';
|
||||
code: "NO_FILE" | "INVALID_FILE_TYPE" | "FILE_TOO_LARGE" | "UPLOAD_FAILED";
|
||||
}
|
||||
|
||||
export function validateFile(file: File) {
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
return {
|
||||
message: `File size exceeds ${MAX_FILE_SIZE_BYTES / 1024 / 1024}MB limit.`,
|
||||
code: 'FILE_TOO_LARGE' as const,
|
||||
code: "FILE_TOO_LARGE" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isAllowedFileType(file.type)) {
|
||||
return {
|
||||
message: `File type '${file.type}' is not supported.`,
|
||||
code: 'INVALID_FILE_TYPE' as const,
|
||||
code: "INVALID_FILE_TYPE" as const,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import * as z from 'zod';
|
||||
import { HttpError } from 'wasp/server';
|
||||
import { type File } from 'wasp/entities';
|
||||
import { type File } from "wasp/entities";
|
||||
import { HttpError } from "wasp/server";
|
||||
import {
|
||||
type CreateFile,
|
||||
type GetAllFilesByUser,
|
||||
type GetDownloadFileSignedURL,
|
||||
} from 'wasp/server/operations';
|
||||
} from "wasp/server/operations";
|
||||
import * as z from "zod";
|
||||
|
||||
import { getUploadFileSignedURLFromS3, getDownloadFileSignedURLFromS3 } from './s3Utils';
|
||||
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
|
||||
import { ALLOWED_FILE_TYPES } from './validation';
|
||||
import { ensureArgsSchemaOrThrowHttpError } from "../server/validation";
|
||||
import {
|
||||
getDownloadFileSignedURLFromS3,
|
||||
getUploadFileSignedURLFromS3,
|
||||
} from "./s3Utils";
|
||||
import { ALLOWED_FILE_TYPES } from "./validation";
|
||||
|
||||
const createFileInputSchema = z.object({
|
||||
fileType: z.enum(ALLOWED_FILE_TYPES),
|
||||
@@ -29,13 +32,17 @@ export const createFile: CreateFile<
|
||||
throw new HttpError(401);
|
||||
}
|
||||
|
||||
const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(createFileInputSchema, rawArgs);
|
||||
const { fileType, fileName } = ensureArgsSchemaOrThrowHttpError(
|
||||
createFileInputSchema,
|
||||
rawArgs,
|
||||
);
|
||||
|
||||
const { s3UploadUrl, s3UploadFields, key } = await getUploadFileSignedURLFromS3({
|
||||
fileType,
|
||||
fileName,
|
||||
userId: context.user.id,
|
||||
});
|
||||
const { s3UploadUrl, s3UploadFields, key } =
|
||||
await getUploadFileSignedURLFromS3({
|
||||
fileType,
|
||||
fileName,
|
||||
userId: context.user.id,
|
||||
});
|
||||
|
||||
await context.entities.File.create({
|
||||
data: {
|
||||
@@ -53,7 +60,10 @@ export const createFile: CreateFile<
|
||||
};
|
||||
};
|
||||
|
||||
export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (_args, context) => {
|
||||
export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (
|
||||
_args,
|
||||
context,
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new HttpError(401);
|
||||
}
|
||||
@@ -64,19 +74,26 @@ export const getAllFilesByUser: GetAllFilesByUser<void, File[]> = async (_args,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getDownloadFileSignedURLInputSchema = z.object({ key: z.string().nonempty() });
|
||||
const getDownloadFileSignedURLInputSchema = z.object({
|
||||
key: z.string().nonempty(),
|
||||
});
|
||||
|
||||
type GetDownloadFileSignedURLInput = z.infer<typeof getDownloadFileSignedURLInputSchema>;
|
||||
type GetDownloadFileSignedURLInput = z.infer<
|
||||
typeof getDownloadFileSignedURLInputSchema
|
||||
>;
|
||||
|
||||
export const getDownloadFileSignedURL: GetDownloadFileSignedURL<
|
||||
GetDownloadFileSignedURLInput,
|
||||
string
|
||||
> = async (rawArgs, _context) => {
|
||||
const { key } = ensureArgsSchemaOrThrowHttpError(getDownloadFileSignedURLInputSchema, rawArgs);
|
||||
const { key } = ensureArgsSchemaOrThrowHttpError(
|
||||
getDownloadFileSignedURLInputSchema,
|
||||
rawArgs,
|
||||
);
|
||||
return await getDownloadFileSignedURLFromS3({ key });
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
|
||||
import { MAX_FILE_SIZE_BYTES } from './validation';
|
||||
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { randomUUID } from "crypto";
|
||||
import * as path from "path";
|
||||
import { MAX_FILE_SIZE_BYTES } from "./validation";
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region: process.env.AWS_S3_REGION,
|
||||
@@ -19,23 +19,32 @@ type S3Upload = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export const getUploadFileSignedURLFromS3 = async ({ fileName, fileType, userId }: S3Upload) => {
|
||||
export const getUploadFileSignedURLFromS3 = async ({
|
||||
fileName,
|
||||
fileType,
|
||||
userId,
|
||||
}: S3Upload) => {
|
||||
const key = getS3Key(fileName, userId);
|
||||
|
||||
const { url: s3UploadUrl, fields: s3UploadFields } = await createPresignedPost(s3Client, {
|
||||
Bucket: process.env.AWS_S3_FILES_BUCKET!,
|
||||
Key: key,
|
||||
Conditions: [['content-length-range', 0, MAX_FILE_SIZE_BYTES]],
|
||||
Fields: {
|
||||
'Content-Type': fileType,
|
||||
},
|
||||
Expires: 3600,
|
||||
});
|
||||
const { url: s3UploadUrl, fields: s3UploadFields } =
|
||||
await createPresignedPost(s3Client, {
|
||||
Bucket: process.env.AWS_S3_FILES_BUCKET!,
|
||||
Key: key,
|
||||
Conditions: [["content-length-range", 0, MAX_FILE_SIZE_BYTES]],
|
||||
Fields: {
|
||||
"Content-Type": fileType,
|
||||
},
|
||||
Expires: 3600,
|
||||
});
|
||||
|
||||
return { s3UploadUrl, key, s3UploadFields };
|
||||
};
|
||||
|
||||
export const getDownloadFileSignedURLFromS3 = async ({ key }: { key: string }) => {
|
||||
export const getDownloadFileSignedURLFromS3 = async ({
|
||||
key,
|
||||
}: {
|
||||
key: string;
|
||||
}) => {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: process.env.AWS_S3_FILES_BUCKET,
|
||||
Key: key,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Set this to the max file size you want to allow (currently 5MB).
|
||||
export const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
export const ALLOWED_FILE_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'application/pdf',
|
||||
'text/*',
|
||||
'video/quicktime',
|
||||
'video/mp4',
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"application/pdf",
|
||||
"text/*",
|
||||
"video/quicktime",
|
||||
"video/mp4",
|
||||
] as const;
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import HighlightedFeature from './components/HighlightedFeature';
|
||||
import aiReadyDark from '../client/static/assets/aiready-dark.webp';
|
||||
import aiReady from '../client/static/assets/aiready.webp';
|
||||
import aiReadyDark from "../client/static/assets/aiready-dark.webp";
|
||||
import aiReady from "../client/static/assets/aiready.webp";
|
||||
import HighlightedFeature from "./components/HighlightedFeature";
|
||||
|
||||
export default function AIReady() {
|
||||
return (
|
||||
<HighlightedFeature
|
||||
name='Example Feature Highlight'
|
||||
description='Yo! Use this component to show off the most important features in your app.'
|
||||
name="Example Feature Highlight"
|
||||
description="Yo! Use this component to show off the most important features in your app."
|
||||
highlightedComponent={<AIReadyExample />}
|
||||
direction='row-reverse'
|
||||
direction="row-reverse"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const AIReadyExample = () => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<img src={aiReady} alt='AI Ready' className='dark:hidden' />
|
||||
<img src={aiReadyDark} alt='AI Ready' className='hidden dark:block' />
|
||||
<div className="w-full">
|
||||
<img src={aiReady} alt="AI Ready" className="dark:hidden" />
|
||||
<img src={aiReadyDark} alt="AI Ready" className="hidden dark:block" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import ExamplesCarousel from './components/ExamplesCarousel';
|
||||
import FAQ from './components/FAQ';
|
||||
import FeaturesGrid from './components/FeaturesGrid';
|
||||
import Footer from './components/Footer';
|
||||
import Hero from './components/Hero';
|
||||
import Testimonials from './components/Testimonials';
|
||||
import AIReady from './ExampleHighlightedFeature';
|
||||
import { examples, faqs, features, footerNavigation, testimonials } from './contentSections';
|
||||
import ExamplesCarousel from "./components/ExamplesCarousel";
|
||||
import FAQ from "./components/FAQ";
|
||||
import FeaturesGrid from "./components/FeaturesGrid";
|
||||
import Footer from "./components/Footer";
|
||||
import Hero from "./components/Hero";
|
||||
import Testimonials from "./components/Testimonials";
|
||||
import {
|
||||
examples,
|
||||
faqs,
|
||||
features,
|
||||
footerNavigation,
|
||||
testimonials,
|
||||
} from "./contentSections";
|
||||
import AIReady from "./ExampleHighlightedFeature";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className='bg-background text-foreground'>
|
||||
<main className='isolate'>
|
||||
<div className="bg-background text-foreground">
|
||||
<main className="isolate">
|
||||
<Hero />
|
||||
<ExamplesCarousel examples={examples} />
|
||||
<AIReady />
|
||||
@@ -22,4 +28,3 @@ export default function LandingPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import AstroLogo from '../logos/AstroLogo';
|
||||
import OpenAILogo from '../logos/OpenAILogo';
|
||||
import PrismaLogo from '../logos/PrismaLogo';
|
||||
import SalesforceLogo from '../logos/SalesforceLogo';
|
||||
import AstroLogo from "../logos/AstroLogo";
|
||||
import OpenAILogo from "../logos/OpenAILogo";
|
||||
import PrismaLogo from "../logos/PrismaLogo";
|
||||
import SalesforceLogo from "../logos/SalesforceLogo";
|
||||
|
||||
export default function Clients() {
|
||||
return (
|
||||
<div className='mt-12 mx-auto max-w-7xl px-6 lg:px-8 flex flex-col items-between gap-y-6'>
|
||||
<h2 className='mb-6 text-center font-semibold tracking-wide text-muted-foreground'>
|
||||
<div className="items-between mx-auto mt-12 flex max-w-7xl flex-col gap-y-6 px-6 lg:px-8">
|
||||
<h2 className="text-muted-foreground mb-6 text-center font-semibold tracking-wide">
|
||||
Built with / Used by:
|
||||
</h2>
|
||||
|
||||
<div className='mx-auto grid max-w-lg grid-cols-2 items-center gap-x-8 gap-y-12 sm:max-w-xl md:grid-cols-4 sm:gap-x-10 sm:gap-y-14 lg:mx-0 lg:max-w-none'>
|
||||
{[<SalesforceLogo />, <PrismaLogo />, <AstroLogo />, <OpenAILogo />].map((logo, index) => (
|
||||
<div className="mx-auto grid max-w-lg grid-cols-2 items-center gap-x-8 gap-y-12 sm:max-w-xl sm:gap-x-10 sm:gap-y-14 md:grid-cols-4 lg:mx-0 lg:max-w-none">
|
||||
{[
|
||||
<SalesforceLogo />,
|
||||
<PrismaLogo />,
|
||||
<AstroLogo />,
|
||||
<OpenAILogo />,
|
||||
].map((logo, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='flex justify-center col-span-1 max-h-12 w-full object-contain opacity-80 hover:opacity-100 transition-opacity'
|
||||
className="col-span-1 flex max-h-12 w-full justify-center object-contain opacity-80 transition-opacity hover:opacity-100"
|
||||
>
|
||||
{logo}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { forwardRef, useEffect, useRef, useState } from 'react';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { Card, CardContent } from "../../components/ui/card";
|
||||
|
||||
const EXAMPLES_CAROUSEL_INTERVAL = 3000;
|
||||
const EXAMPLES_CAROUSEL_SCROLL_TIMEOUT = 200;
|
||||
@@ -21,10 +21,13 @@ const ExamplesCarousel = ({ examples }: { examples: ExampleApp[] }) => {
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(([entry]) => setIsInView(entry.isIntersecting), {
|
||||
threshold: 0.5,
|
||||
rootMargin: '-200px 0px -100px 0px',
|
||||
});
|
||||
observerRef.current = new IntersectionObserver(
|
||||
([entry]) => setIsInView(entry.isIntersecting),
|
||||
{
|
||||
threshold: 0.5,
|
||||
rootMargin: "-200px 0px -100px 0px",
|
||||
},
|
||||
);
|
||||
|
||||
if (containerRef.current) {
|
||||
observerRef.current.observe(containerRef.current);
|
||||
@@ -55,17 +58,22 @@ const ExamplesCarousel = ({ examples }: { examples: ExampleApp[] }) => {
|
||||
scrollTimeoutRef.current = setTimeout(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const targetCard = scrollContainer.children[currentExample] as HTMLElement | undefined;
|
||||
const targetCard = scrollContainer.children[currentExample] as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
|
||||
if (targetCard) {
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const cardRect = targetCard.getBoundingClientRect();
|
||||
const scrollLeft =
|
||||
targetCard.offsetLeft - scrollContainer.offsetLeft - containerRect.width / 2 + cardRect.width / 2;
|
||||
targetCard.offsetLeft -
|
||||
scrollContainer.offsetLeft -
|
||||
containerRect.width / 2 +
|
||||
cardRect.width / 2;
|
||||
|
||||
scrollContainer.scrollTo({
|
||||
left: scrollLeft,
|
||||
behavior: 'smooth',
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -98,12 +106,14 @@ const ExamplesCarousel = ({ examples }: { examples: ExampleApp[] }) => {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='relative w-screen left-1/2 -translate-x-1/2 flex flex-col items-center my-16'
|
||||
className="relative left-1/2 my-16 flex w-screen -translate-x-1/2 flex-col items-center"
|
||||
>
|
||||
<h2 className='mb-6 text-center font-semibold tracking-wide text-muted-foreground'>Used by:</h2>
|
||||
<div className='w-full max-w-full overflow-hidden'>
|
||||
<h2 className="text-muted-foreground mb-6 text-center font-semibold tracking-wide">
|
||||
Used by:
|
||||
</h2>
|
||||
<div className="w-full max-w-full overflow-hidden">
|
||||
<div
|
||||
className='flex overflow-x-auto no-scrollbar scroll-smooth pb-10 pt-4 gap-4 px-4 snap-x snap-mandatory'
|
||||
className="no-scrollbar flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth px-4 pb-10 pt-4"
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
{examples.map((example, index) => (
|
||||
@@ -133,29 +143,35 @@ const ExampleCard = forwardRef<HTMLDivElement, ExampleCardProps>(
|
||||
return (
|
||||
<a
|
||||
href={example.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex-shrink-0 snap-center'
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0 snap-center"
|
||||
onMouseEnter={() => onMouseEnter(index)}
|
||||
>
|
||||
<Card
|
||||
ref={ref}
|
||||
className='overflow-hidden w-[280px] sm:w-[320px] md:w-[350px] transition-all duration-200 hover:scale-105'
|
||||
variant={isCurrent ? 'default' : 'faded'}
|
||||
className="w-[280px] overflow-hidden transition-all duration-200 hover:scale-105 sm:w-[320px] md:w-[350px]"
|
||||
variant={isCurrent ? "default" : "faded"}
|
||||
>
|
||||
<CardContent className='p-0 h-full'>
|
||||
<img src={example.imageSrc} alt={example.name} className='w-full h-auto aspect-video' />
|
||||
<div className='p-4'>
|
||||
<p className='font-bold'>{example.name}</p>
|
||||
<p className='text-xs text-muted-foreground'>{example.description}</p>
|
||||
<CardContent className="h-full p-0">
|
||||
<img
|
||||
src={example.imageSrc}
|
||||
alt={example.name}
|
||||
className="aspect-video h-auto w-full"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<p className="font-bold">{example.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{example.description}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ExampleCard.displayName = 'ExampleCard';
|
||||
ExampleCard.displayName = "ExampleCard";
|
||||
|
||||
export default ExamplesCarousel;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../../components/ui/accordion';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "../../components/ui/accordion";
|
||||
|
||||
interface FAQ {
|
||||
id: number;
|
||||
@@ -9,28 +14,30 @@ interface FAQ {
|
||||
|
||||
export default function FAQ({ faqs }: { faqs: FAQ[] }) {
|
||||
return (
|
||||
<div className='mt-32 mx-auto max-w-4xl px-6 pb-8 sm:pb-24 sm:pt-12 lg:max-w-7xl lg:px-8 lg:py-32'>
|
||||
<h2 className='text-2xl font-bold leading-10 tracking-tight text-foreground text-center mb-12'>
|
||||
<div className="mx-auto mt-32 max-w-4xl px-6 pb-8 sm:pb-24 sm:pt-12 lg:max-w-7xl lg:px-8 lg:py-32">
|
||||
<h2 className="text-foreground mb-12 text-center text-2xl font-bold leading-10 tracking-tight">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
|
||||
<Accordion type='single' collapsible className='w-full space-y-4'>
|
||||
<Accordion type="single" collapsible className="w-full space-y-4">
|
||||
{faqs.map((faq) => (
|
||||
<AccordionItem
|
||||
key={faq.id}
|
||||
value={`faq-${faq.id}`}
|
||||
className='border border-border rounded-lg px-6 py-2 hover:bg-muted/20 transition-all duration-200'
|
||||
className="border-border hover:bg-muted/20 rounded-lg border px-6 py-2 transition-all duration-200"
|
||||
>
|
||||
<AccordionTrigger className='text-left text-base font-semibold leading-7 text-foreground hover:text-primary transition-colors duration-200'>
|
||||
<AccordionTrigger className="text-foreground hover:text-primary text-left text-base font-semibold leading-7 transition-colors duration-200">
|
||||
{faq.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className='text-muted-foreground'>
|
||||
<div className='flex flex-col items-start justify-between gap-4'>
|
||||
<p className='text-base leading-7 text-muted-foreground flex-1'>{faq.answer}</p>
|
||||
<AccordionContent className="text-muted-foreground">
|
||||
<div className="flex flex-col items-start justify-between gap-4">
|
||||
<p className="text-muted-foreground flex-1 text-base leading-7">
|
||||
{faq.answer}
|
||||
</p>
|
||||
{faq.href && (
|
||||
<a
|
||||
href={faq.href}
|
||||
className='text-base leading-7 text-primary hover:text-primary/80 transition-colors duration-200 font-medium whitespace-nowrap flex-shrink-0'
|
||||
className="text-primary hover:text-primary/80 flex-shrink-0 whitespace-nowrap text-base font-medium leading-7 transition-colors duration-200"
|
||||
>
|
||||
Learn more →
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import SectionTitle from './SectionTitle';
|
||||
import SectionTitle from "./SectionTitle";
|
||||
|
||||
export interface Feature {
|
||||
name: string;
|
||||
@@ -9,26 +9,28 @@ export interface Feature {
|
||||
|
||||
export default function Features({ features }: { features: Feature[] }) {
|
||||
return (
|
||||
<div id='features' className='mx-auto mt-48 max-w-7xl px-6 lg:px-8'>
|
||||
<div id="features" className="mx-auto mt-48 max-w-7xl px-6 lg:px-8">
|
||||
<SectionTitle
|
||||
title={
|
||||
<p className='mt-2 text-4xl font-bold tracking-tight text-foreground sm:text-5xl'>
|
||||
The <span className='text-secondary'>Best</span> Features
|
||||
<p className="text-foreground mt-2 text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
The <span className="text-secondary">Best</span> Features
|
||||
</p>
|
||||
}
|
||||
description="Don't work harder. Work smarter."
|
||||
/>
|
||||
<div className='mx-auto mt-16 max-w-2xl sm:mt-20 lg:mt-24 lg:max-w-4xl'>
|
||||
<dl className='grid max-w-xl grid-cols-1 gap-x-8 gap-y-10 lg:max-w-none lg:grid-cols-2 lg:gap-y-16'>
|
||||
<div className="mx-auto mt-16 max-w-2xl sm:mt-20 lg:mt-24 lg:max-w-4xl">
|
||||
<dl className="grid max-w-xl grid-cols-1 gap-x-8 gap-y-10 lg:max-w-none lg:grid-cols-2 lg:gap-y-16">
|
||||
{features.map((feature) => (
|
||||
<div key={feature.name} className='relative pl-16'>
|
||||
<dt className='text-base font-semibold leading-7 text-foreground'>
|
||||
<div className='absolute left-0 top-0 flex h-10 w-10 items-center justify-center border border-accent bg-accent/30 rounded-lg'>
|
||||
<div className='text-2xl'>{feature.icon}</div>
|
||||
<div key={feature.name} className="relative pl-16">
|
||||
<dt className="text-foreground text-base font-semibold leading-7">
|
||||
<div className="border-accent bg-accent/30 absolute left-0 top-0 flex h-10 w-10 items-center justify-center rounded-lg border">
|
||||
<div className="text-2xl">{feature.icon}</div>
|
||||
</div>
|
||||
{feature.name}
|
||||
</dt>
|
||||
<dd className='mt-2 text-base leading-7 text-muted-foreground'>{feature.description}</dd>
|
||||
<dd className="text-muted-foreground mt-2 text-base leading-7">
|
||||
{feature.description}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardDescription, CardTitle } from '../../components/ui/card';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Feature } from './Features';
|
||||
import SectionTitle from './SectionTitle';
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Feature } from "./Features";
|
||||
import SectionTitle from "./SectionTitle";
|
||||
|
||||
export interface GridFeature extends Omit<Feature, 'icon'> {
|
||||
export interface GridFeature extends Omit<Feature, "icon"> {
|
||||
icon?: React.ReactNode;
|
||||
emoji?: string;
|
||||
direction?: 'col' | 'row' | 'col-reverse' | 'row-reverse';
|
||||
align?: 'center' | 'left';
|
||||
size: 'small' | 'medium' | 'large';
|
||||
direction?: "col" | "row" | "col-reverse" | "row-reverse";
|
||||
align?: "center" | "left";
|
||||
size: "small" | "medium" | "large";
|
||||
fullWidthIcon?: boolean;
|
||||
}
|
||||
|
||||
@@ -18,18 +23,27 @@ interface FeaturesGridProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FeaturesGrid = ({ features, className = '' }: FeaturesGridProps) => {
|
||||
const FeaturesGrid = ({ features, className = "" }: FeaturesGridProps) => {
|
||||
return (
|
||||
<div className='flex flex-col gap-4 my-16 md:my-24 lg:my-40 max-w-7xl mx-auto' id='features'>
|
||||
<SectionTitle title='Features' description='These are some of the features of the product.' />
|
||||
<div
|
||||
className="mx-auto my-16 flex max-w-7xl flex-col gap-4 md:my-24 lg:my-40"
|
||||
id="features"
|
||||
>
|
||||
<SectionTitle
|
||||
title="Features"
|
||||
description="These are some of the features of the product."
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-2 gap-4 md:grid-cols-4 lg:grid-cols-6 mx-4 md:mx-6 lg:mx-8 auto-rows-[minmax(140px,auto)]',
|
||||
className
|
||||
"mx-4 grid auto-rows-[minmax(140px,auto)] grid-cols-2 gap-4 md:mx-6 md:grid-cols-4 lg:mx-8 lg:grid-cols-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{features.map((feature) => (
|
||||
<FeaturesGridItem key={feature.name + feature.description} {...feature} />
|
||||
<FeaturesGridItem
|
||||
key={feature.name + feature.description}
|
||||
{...feature}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,56 +56,77 @@ function FeaturesGridItem({
|
||||
icon,
|
||||
emoji,
|
||||
href,
|
||||
direction = 'col',
|
||||
align = 'center',
|
||||
size = 'medium',
|
||||
direction = "col",
|
||||
align = "center",
|
||||
size = "medium",
|
||||
fullWidthIcon = true,
|
||||
}: GridFeature) {
|
||||
const gridFeatureSizeToClasses: Record<GridFeature['size'], string> = {
|
||||
small: 'col-span-1',
|
||||
medium: 'col-span-2 md:col-span-2 lg:col-span-2',
|
||||
large: 'col-span-2 md:col-span-2 lg:col-span-2 row-span-2',
|
||||
const gridFeatureSizeToClasses: Record<GridFeature["size"], string> = {
|
||||
small: "col-span-1",
|
||||
medium: "col-span-2 md:col-span-2 lg:col-span-2",
|
||||
large: "col-span-2 md:col-span-2 lg:col-span-2 row-span-2",
|
||||
};
|
||||
|
||||
const directionToClass: Record<NonNullable<GridFeature['direction']>, string> = {
|
||||
col: 'flex-col',
|
||||
row: 'flex-row',
|
||||
'row-reverse': 'flex-row-reverse',
|
||||
'col-reverse': 'flex-col-reverse',
|
||||
const directionToClass: Record<
|
||||
NonNullable<GridFeature["direction"]>,
|
||||
string
|
||||
> = {
|
||||
col: "flex-col",
|
||||
row: "flex-row",
|
||||
"row-reverse": "flex-row-reverse",
|
||||
"col-reverse": "flex-col-reverse",
|
||||
};
|
||||
|
||||
const gridFeatureCard = (
|
||||
<Card
|
||||
className={cn(
|
||||
'h-full min-h-[140px] transition-all duration-300 hover:shadow-lg cursor-pointer',
|
||||
gridFeatureSizeToClasses[size]
|
||||
"h-full min-h-[140px] cursor-pointer transition-all duration-300 hover:shadow-lg",
|
||||
gridFeatureSizeToClasses[size],
|
||||
)}
|
||||
variant='bento'
|
||||
variant="bento"
|
||||
>
|
||||
<CardContent className='p-4 h-full flex flex-col justify-center items-center'>
|
||||
<CardContent className="flex h-full flex-col items-center justify-center p-4">
|
||||
{fullWidthIcon && (icon || emoji) ? (
|
||||
<div className='w-full flex justify-center items-center mb-3'>
|
||||
{icon ? icon : emoji ? <span className='text-4xl'>{emoji}</span> : null}
|
||||
<div className="mb-3 flex w-full items-center justify-center">
|
||||
{icon ? (
|
||||
icon
|
||||
) : emoji ? (
|
||||
<span className="text-4xl">{emoji}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3',
|
||||
"flex items-center gap-3",
|
||||
directionToClass[direction],
|
||||
align === 'center' ? 'justify-center items-center' : 'justify-start'
|
||||
align === "center"
|
||||
? "items-center justify-center"
|
||||
: "justify-start",
|
||||
)}
|
||||
>
|
||||
<div className='flex h-10 w-10 items-center justify-center rounded-lg'>
|
||||
{icon ? icon : emoji ? <span className='text-2xl'>{emoji}</span> : null}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
{icon ? (
|
||||
icon
|
||||
) : emoji ? (
|
||||
<span className="text-2xl">{emoji}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<CardTitle className={cn(align === 'center' ? 'text-center' : 'text-left')}>{name}</CardTitle>
|
||||
<CardTitle
|
||||
className={cn(align === "center" ? "text-center" : "text-left")}
|
||||
>
|
||||
{name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
)}
|
||||
{fullWidthIcon && (icon || emoji) && <CardTitle className='text-center mb-2'>{name}</CardTitle>}
|
||||
{fullWidthIcon && (icon || emoji) && (
|
||||
<CardTitle className="mb-2 text-center">{name}</CardTitle>
|
||||
)}
|
||||
<CardDescription
|
||||
className={cn(
|
||||
'text-xs leading-relaxed',
|
||||
fullWidthIcon || direction === 'col' || align === 'center' ? 'text-center' : 'text-left'
|
||||
"text-xs leading-relaxed",
|
||||
fullWidthIcon || direction === "col" || align === "center"
|
||||
? "text-center"
|
||||
: "text-left",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
@@ -102,7 +137,12 @@ function FeaturesGridItem({
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} target='_blank' rel='noopener noreferrer' className={gridFeatureSizeToClasses[size]}>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={gridFeatureSizeToClasses[size]}
|
||||
>
|
||||
{gridFeatureCard}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
interface NavigationItem {
|
||||
name: string;
|
||||
href: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Footer({ footerNavigation }: {
|
||||
export default function Footer({
|
||||
footerNavigation,
|
||||
}: {
|
||||
footerNavigation: {
|
||||
app: NavigationItem[]
|
||||
company: NavigationItem[]
|
||||
}
|
||||
app: NavigationItem[];
|
||||
company: NavigationItem[];
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<div className='mx-auto mt-6 max-w-7xl px-6 lg:px-8 dark:bg-boxdark-2'>
|
||||
<div className="dark:bg-boxdark-2 mx-auto mt-6 max-w-7xl px-6 lg:px-8">
|
||||
<footer
|
||||
aria-labelledby='footer-heading'
|
||||
className='relative border-t border-gray-900/10 dark:border-gray-200/10 py-24 sm:mt-32'
|
||||
aria-labelledby="footer-heading"
|
||||
className="relative border-t border-gray-900/10 py-24 sm:mt-32 dark:border-gray-200/10"
|
||||
>
|
||||
<h2 id='footer-heading' className='sr-only'>
|
||||
<h2 id="footer-heading" className="sr-only">
|
||||
Footer
|
||||
</h2>
|
||||
<div className='flex items-start justify-end mt-10 gap-20'>
|
||||
<div className="mt-10 flex items-start justify-end gap-20">
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold leading-6 text-gray-900 dark:text-white'>App</h3>
|
||||
<ul role='list' className='mt-6 space-y-4'>
|
||||
<h3 className="text-sm font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
App
|
||||
</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
{footerNavigation.app.map((item) => (
|
||||
<li key={item.name}>
|
||||
<a href={item.href} className='text-sm leading-6 text-gray-600 hover:text-gray-900 dark:text-white'>
|
||||
<a
|
||||
href={item.href}
|
||||
className="text-sm leading-6 text-gray-600 hover:text-gray-900 dark:text-white"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
@@ -32,11 +39,16 @@ export default function Footer({ footerNavigation }: {
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold leading-6 text-gray-900 dark:text-white'>Company</h3>
|
||||
<ul role='list' className='mt-6 space-y-4'>
|
||||
<h3 className="text-sm font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
Company
|
||||
</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
{footerNavigation.company.map((item) => (
|
||||
<li key={item.name}>
|
||||
<a href={item.href} className='text-sm leading-6 text-gray-600 hover:text-gray-900 dark:text-white'>
|
||||
<a
|
||||
href={item.href}
|
||||
className="text-sm leading-6 text-gray-600 hover:text-gray-900 dark:text-white"
|
||||
>
|
||||
{item.name}
|
||||
</a>
|
||||
</li>
|
||||
@@ -46,5 +58,5 @@ export default function Footer({ footerNavigation }: {
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,51 +1,53 @@
|
||||
import { Link as WaspRouterLink, routes } from 'wasp/client/router';
|
||||
import openSaasBannerDark from '../../client/static/open-saas-banner-dark.png';
|
||||
import openSaasBannerLight from '../../client/static/open-saas-banner-light.png';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Link as WaspRouterLink, routes } from "wasp/client/router";
|
||||
import openSaasBannerDark from "../../client/static/open-saas-banner-dark.png";
|
||||
import openSaasBannerLight from "../../client/static/open-saas-banner-light.png";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
<div className='relative pt-14 w-full'>
|
||||
<div className="relative w-full pt-14">
|
||||
<TopGradient />
|
||||
<BottomGradient />
|
||||
<div className='md:p-24'>
|
||||
<div className='mx-auto max-w-8xl px-6 lg:px-8'>
|
||||
<div className='lg:mb-18 mx-auto max-w-3xl text-center'>
|
||||
<h1 className='text-5xl font-bold text-foreground sm:text-6xl'>
|
||||
Some <span className='italic'>cool</span> words about{' '}
|
||||
<span className='text-gradient-primary'>your product</span>
|
||||
<div className="md:p-24">
|
||||
<div className="max-w-8xl mx-auto px-6 lg:px-8">
|
||||
<div className="lg:mb-18 mx-auto max-w-3xl text-center">
|
||||
<h1 className="text-foreground text-5xl font-bold sm:text-6xl">
|
||||
Some <span className="italic">cool</span> words about{" "}
|
||||
<span className="text-gradient-primary">your product</span>
|
||||
</h1>
|
||||
<p className='mt-6 mx-auto max-w-2xl text-lg leading-8 text-muted-foreground'>
|
||||
<p className="text-muted-foreground mx-auto mt-6 max-w-2xl text-lg leading-8">
|
||||
With some more exciting words about your product!
|
||||
</p>
|
||||
<div className='mt-10 flex items-center justify-center gap-x-6'>
|
||||
<Button size='lg' variant='outline' asChild>
|
||||
<WaspRouterLink to={routes.PricingPageRoute.to}>Learn More</WaspRouterLink>
|
||||
<div className="mt-10 flex items-center justify-center gap-x-6">
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<WaspRouterLink to={routes.PricingPageRoute.to}>
|
||||
Learn More
|
||||
</WaspRouterLink>
|
||||
</Button>
|
||||
<Button size='lg' variant='default' asChild>
|
||||
<Button size="lg" variant="default" asChild>
|
||||
<WaspRouterLink to={routes.SignupRoute.to}>
|
||||
Get Started <span aria-hidden='true'>→</span>
|
||||
Get Started <span aria-hidden="true">→</span>
|
||||
</WaspRouterLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-14 flow-root sm:mt-14'>
|
||||
<div className='hidden md:flex m-2 justify-center rounded-xl lg:-m-4 lg:rounded-2xl lg:p-4'>
|
||||
<div className="mt-14 flow-root sm:mt-14">
|
||||
<div className="m-2 hidden justify-center rounded-xl md:flex lg:-m-4 lg:rounded-2xl lg:p-4">
|
||||
<img
|
||||
src={openSaasBannerLight}
|
||||
alt='App screenshot'
|
||||
alt="App screenshot"
|
||||
width={1000}
|
||||
height={530}
|
||||
loading='lazy'
|
||||
className='rounded-md shadow-2xl ring-1 ring-gray-900/10 dark:hidden'
|
||||
loading="lazy"
|
||||
className="rounded-md shadow-2xl ring-1 ring-gray-900/10 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src={openSaasBannerDark}
|
||||
alt='App screenshot'
|
||||
alt="App screenshot"
|
||||
width={1000}
|
||||
height={530}
|
||||
loading='lazy'
|
||||
className='rounded-md shadow-2xl ring-1 ring-gray-900/10 hidden dark:block'
|
||||
loading="lazy"
|
||||
className="hidden rounded-md shadow-2xl ring-1 ring-gray-900/10 dark:block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,13 +60,14 @@ export default function Hero() {
|
||||
function TopGradient() {
|
||||
return (
|
||||
<div
|
||||
className='absolute top-0 right-0 -z-10 transform-gpu overflow-hidden w-full blur-3xl sm:top-0'
|
||||
aria-hidden='true'
|
||||
className="absolute right-0 top-0 -z-10 w-full transform-gpu overflow-hidden blur-3xl sm:top-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className='aspect-[1020/880] w-[70rem] flex-none sm:right-1/4 sm:translate-x-1/2 dark:hidden bg-gradient-to-tr from-amber-400 to-purple-300 opacity-10'
|
||||
className="aspect-[1020/880] w-[70rem] flex-none bg-gradient-to-tr from-amber-400 to-purple-300 opacity-10 sm:right-1/4 sm:translate-x-1/2 dark:hidden"
|
||||
style={{
|
||||
clipPath: 'polygon(80% 20%, 90% 55%, 50% 100%, 70% 30%, 20% 50%, 50% 0)',
|
||||
clipPath:
|
||||
"polygon(80% 20%, 90% 55%, 50% 100%, 70% 30%, 20% 50%, 50% 0)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -74,13 +77,13 @@ function TopGradient() {
|
||||
function BottomGradient() {
|
||||
return (
|
||||
<div
|
||||
className='absolute inset-x-0 top-[calc(100%-40rem)] sm:top-[calc(100%-65rem)] -z-10 transform-gpu overflow-hidden blur-3xl'
|
||||
aria-hidden='true'
|
||||
className="absolute inset-x-0 top-[calc(100%-40rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-65rem)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className='relative aspect-[1020/880] sm:-left-3/4 sm:translate-x-1/4 dark:hidden bg-gradient-to-br from-amber-400 to-purple-300 opacity-10 w-[90rem]'
|
||||
className="relative aspect-[1020/880] w-[90rem] bg-gradient-to-br from-amber-400 to-purple-300 opacity-10 sm:-left-3/4 sm:translate-x-1/4 dark:hidden"
|
||||
style={{
|
||||
clipPath: 'ellipse(80% 30% at 80% 50%)',
|
||||
clipPath: "ellipse(80% 30% at 80% 50%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface FeatureProps {
|
||||
name: string;
|
||||
description: string | React.ReactNode;
|
||||
direction?: 'row' | 'row-reverse';
|
||||
direction?: "row" | "row-reverse";
|
||||
highlightedComponent: React.ReactNode;
|
||||
tilt?: 'left' | 'right';
|
||||
tilt?: "left" | "right";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,34 +15,34 @@ interface FeatureProps {
|
||||
const HighlightedFeature = ({
|
||||
name,
|
||||
description,
|
||||
direction = 'row',
|
||||
direction = "row",
|
||||
highlightedComponent,
|
||||
tilt,
|
||||
}: FeatureProps) => {
|
||||
const tiltToClass: Record<Required<FeatureProps>['tilt'], string> = {
|
||||
left: 'rotate-1',
|
||||
right: '-rotate-1',
|
||||
const tiltToClass: Record<Required<FeatureProps>["tilt"], string> = {
|
||||
left: "rotate-1",
|
||||
right: "-rotate-1",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-6xl mx-auto flex flex-col items-center my-50 gap-x-20 gap-y-10 justify-between px-8 md:px-4 transition-all duration-300 ease-in-out',
|
||||
direction === 'row' ? 'md:flex-row' : 'md:flex-row-reverse'
|
||||
"my-50 mx-auto flex max-w-6xl flex-col items-center justify-between gap-x-20 gap-y-10 px-8 transition-all duration-300 ease-in-out md:px-4",
|
||||
direction === "row" ? "md:flex-row" : "md:flex-row-reverse",
|
||||
)}
|
||||
>
|
||||
<div className='flex-col flex-1'>
|
||||
<h2 className='text-4xl font-bold mb-2'>{name}</h2>
|
||||
{typeof description === 'string' ? (
|
||||
<p className='text-muted-foreground'>{description}</p>
|
||||
<div className="flex-1 flex-col">
|
||||
<h2 className="mb-2 text-4xl font-bold">{name}</h2>
|
||||
{typeof description === "string" ? (
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 my-10 transition-transform duration-300 ease-in-out w-full items-center justify-center',
|
||||
tilt && tiltToClass[tilt]
|
||||
"my-10 flex w-full flex-1 items-center justify-center transition-transform duration-300 ease-in-out",
|
||||
tilt && tiltToClass[tilt],
|
||||
)}
|
||||
>
|
||||
{highlightedComponent}
|
||||
|
||||
@@ -7,20 +7,24 @@ export default function SectionTitle({
|
||||
titleComponent?: React.ReactNode;
|
||||
}) {
|
||||
const titleElement =
|
||||
typeof title === 'string' ? (
|
||||
<h3 className='mt-2 text-4xl font-bold tracking-tight text-foreground sm:text-5xl'>{title}</h3>
|
||||
typeof title === "string" ? (
|
||||
<h3 className="text-foreground mt-2 text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
{title}
|
||||
</h3>
|
||||
) : (
|
||||
title
|
||||
);
|
||||
const descriptionElement =
|
||||
typeof description === 'string' ? (
|
||||
<p className='mt-4 text-lg leading-8 text-muted-foreground'>{description}</p>
|
||||
typeof description === "string" ? (
|
||||
<p className="text-muted-foreground mt-4 text-lg leading-8">
|
||||
{description}
|
||||
</p>
|
||||
) : (
|
||||
description
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='mx-auto max-w-2xl text-center mb-8'>
|
||||
<div className="mx-auto mb-8 max-w-2xl text-center">
|
||||
{titleElement}
|
||||
{descriptionElement}
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user