├── app
├── components
│ ├── atoms
│ │ ├── navbar.tsx
│ │ ├── README.md
│ │ ├── language-item.tsx
│ │ ├── summary-card.tsx
│ │ ├── kbd.tsx
│ │ ├── nav-item.tsx
│ │ └── collapsible-item.tsx
│ ├── blocks
│ │ ├── README.md
│ │ ├── crud-list.tsx
│ │ ├── profile-dropdown.tsx
│ │ ├── date-range-picker.tsx
│ │ ├── data-table.tsx
│ │ ├── md-sm-sidebar.tsx
│ │ └── sidebar.tsx
│ ├── ui
│ │ ├── skeleton.tsx
│ │ ├── collapsible.tsx
│ │ ├── label.tsx
│ │ ├── textarea.tsx
│ │ ├── input.tsx
│ │ ├── separator.tsx
│ │ ├── checkbox.tsx
│ │ ├── badge.tsx
│ │ ├── tooltip.tsx
│ │ ├── popover.tsx
│ │ ├── toggle.tsx
│ │ ├── avatar.tsx
│ │ ├── toggle-group.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── calendar.tsx
│ │ ├── table.tsx
│ │ ├── dialog.tsx
│ │ ├── form.tsx
│ │ ├── sheet.tsx
│ │ ├── command.tsx
│ │ ├── select.tsx
│ │ └── dropdown-menu.tsx
│ ├── global-pending-indicator.tsx
│ ├── shell.tsx
│ ├── layouts
│ │ └── MainLayout.tsx
│ └── theme-switcher.tsx
├── auth
│ └── README.md
├── lib
│ ├── constants.ts
│ ├── fonts.ts
│ ├── styles.ts
│ ├── seed.ts
│ ├── handle-error.ts
│ ├── utils.ts
│ ├── id.ts
│ ├── validations.ts
│ ├── filter-column.ts
│ ├── export.ts
│ ├── xlsx.ts
│ └── queries.ts
├── routes
│ ├── contributors.create
│ │ └── route.tsx
│ ├── healthcheck.ts
│ ├── contributors.$id.view
│ │ └── route.tsx
│ ├── configure.categories.create
│ │ └── route.tsx
│ ├── configure
│ │ ├── route.tsx
│ │ └── components
│ │ │ └── columns.tsx
│ ├── contributors
│ │ ├── components
│ │ │ ├── progress-bar.tsx
│ │ │ ├── contributor-summary-card.tsx
│ │ │ ├── attendee-heat-map.tsx
│ │ │ ├── detail.tsx
│ │ │ ├── contributor-dropdown.tsx
│ │ │ └── columns.tsx
│ │ └── route.tsx
│ ├── _index
│ │ ├── route.tsx
│ │ └── components
│ │ │ ├── recent-salse.tsx
│ │ │ ├── dashboard.tsx
│ │ │ └── transactions-table.tsx
│ ├── contributors.$id.edit
│ │ └── route.tsx
│ └── inventory.tsx
├── config.shared.ts
├── data
│ ├── db
│ │ ├── index.ts
│ │ ├── seed.ts
│ │ ├── migrate.ts
│ │ ├── utils.ts
│ │ ├── schema.ts
│ │ └── dummy-data.ts
│ └── contributors
│ │ ├── contributors-summary.ts
│ │ └── data.ts
├── entry.client.tsx
├── hooks
│ └── use-debounce.ts
├── @types
│ ├── category.d.ts
│ ├── index.d.ts
│ └── contributors.d.ts
├── services
│ ├── contributors
│ │ └── contributor-service.ts
│ └── category
│ │ └── category-service.ts
├── config
│ ├── nav.ts
│ └── data-table.ts
├── root.tsx
├── entry.server.tsx
└── globals.css
├── .vscode
├── extensions.json
└── settings.json
├── public
├── favicon.ico
└── famcon-logo.png
├── .dockerignore
├── .gitignore
├── postcss.config.js
├── biome.json
├── components.json
├── keynotes.md
├── .github
└── workflows
│ └── code_quality.yaml
├── vite.config.ts
├── tsconfig.json
├── fly.toml
├── Dockerfile
├── server.js
├── package.json
├── docs
└── CRUD.md
├── README.md
└── tailwind.config.cjs
/app/components/atoms/navbar.tsx:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/auth/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to Auth
2 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["biomejs.biome"]
3 | }
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/liwoo/cell-connect/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .cache
2 | .database
3 | .DS_Store
4 | .env
5 | *.log
6 | build
7 | node_modules
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .cache
2 | .database
3 | .DS_Store
4 | .env
5 | *.log
6 | build
7 | node_modules
8 |
--------------------------------------------------------------------------------
/public/famcon-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/liwoo/cell-connect/HEAD/public/famcon-logo.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/app/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const unknownError = "An unknown error occurred. Please try again later."
2 |
3 | export const databasePrefix = "shadcn"
4 |
--------------------------------------------------------------------------------
/app/routes/contributors.create/route.tsx:
--------------------------------------------------------------------------------
1 | export default function ContributorCreate() {
2 | // Implement create form
3 | return
Create Contributor
;
4 | }
5 |
--------------------------------------------------------------------------------
/app/routes/healthcheck.ts:
--------------------------------------------------------------------------------
1 | export function loader() {
2 | return new Response("OK", {
3 | status: 200,
4 | headers: {
5 | "Content-Type": "text/plain",
6 | },
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/app/lib/fonts.ts:
--------------------------------------------------------------------------------
1 | import { GeistMono } from "geist/font/mono";
2 | import { GeistSans } from "geist/font/sans";
3 |
4 | export const fontSans = GeistSans;
5 | export const fontMono = GeistMono;
6 |
--------------------------------------------------------------------------------
/app/routes/contributors.$id.view/route.tsx:
--------------------------------------------------------------------------------
1 | export default function ContributorView({ id }: { id: string }) {
2 | // Implement view details
3 | return View Contributor {id}
;
4 | }
5 |
--------------------------------------------------------------------------------
/app/components/blocks/README.md:
--------------------------------------------------------------------------------
1 | ## Blocks
2 | Blocks are components that belong to a specific page. They are reusable and can be used in multiple pages. They are located in the `app/blocks` directory.
3 |
--------------------------------------------------------------------------------
/app/config.shared.ts:
--------------------------------------------------------------------------------
1 | export const APP_NAME = "remix-shadcn";
2 |
3 | export function title(pageTitle?: string) {
4 | if (!pageTitle) return APP_NAME;
5 |
6 | return `${pageTitle} | ${APP_NAME}`;
7 | }
8 |
--------------------------------------------------------------------------------
/app/routes/configure.categories.create/route.tsx:
--------------------------------------------------------------------------------
1 | export default function CreateCategory() {
2 | return (
3 |
4 |
Create Category
5 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/app/lib/styles.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/app/data/db/index.ts:
--------------------------------------------------------------------------------
1 | import { env } from "@/env.js";
2 | import { drizzle } from "drizzle-orm/postgres-js";
3 | import postgres from "postgres";
4 |
5 | import * as schema from "./schema";
6 |
7 | const client = postgres(env.DATABASE_URL);
8 | export const db = drizzle(client, { schema });
9 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
3 | "files": {
4 | "ignore": ["build/**"]
5 | },
6 | "organizeImports": {
7 | "enabled": true
8 | },
9 | "linter": {
10 | "enabled": true,
11 | "rules": {
12 | "recommended": true
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import { RemixBrowser } from "@remix-run/react";
2 | import { StrictMode, startTransition } from "react";
3 | import { hydrateRoot } from "react-dom/client";
4 |
5 | startTransition(() => {
6 | hydrateRoot(
7 | document,
8 |
9 |
10 | ,
11 | );
12 | });
13 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "biomejs.biome",
3 | "[yaml]": {
4 | "editor.defaultFormatter": "esbenp.prettier-vscode"
5 | },
6 | "[markdown]": {
7 | "editor.defaultFormatter": "esbenp.prettier-vscode"
8 | },
9 | "[toml]": {
10 | "editor.defaultFormatter": "tamasfe.even-better-toml"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/app/routes/configure/route.tsx:
--------------------------------------------------------------------------------
1 | import MainLayout from '@/components/layouts/MainLayout';
2 |
3 | export default function Configure() {
4 | return (
5 |
6 |
7 |
Configure
8 |
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/app/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/styles"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/app/components/atoms/README.md:
--------------------------------------------------------------------------------
1 | ## Atoms
2 | Atoms are very small components that are used to build more complex components. They are the basic building blocks of matter. They are the simplest form of components and cannot be broken down any further. They often include things like buttons, inputs, or headings. They are the basic elements that make up all other components.
3 |
--------------------------------------------------------------------------------
/app/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
2 |
3 | const Collapsible = CollapsiblePrimitive.Root
4 |
5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
6 |
7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
8 |
9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }
10 |
--------------------------------------------------------------------------------
/app/components/atoms/language-item.tsx:
--------------------------------------------------------------------------------
1 | import type { HTMLProps } from "react";
2 |
3 | interface LanguageItemProps extends HTMLProps {
4 | languageProp: string;
5 | }
6 |
7 | export const LanguageItem = ({ languageProp, ...props }: LanguageItemProps) => {
8 | return (
9 |
10 | {languageProp}
11 |
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/styles"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/keynotes.md:
--------------------------------------------------------------------------------
1 | DYNAMIC FILL-UP GRID:
2 | grid-cols-[320px_1fr]: This part defines the custom configuration for the number of columns in a grid layout. In this case, it sets two columns with specific sizes:
3 |
4 | 320px: The first column is a fixed width of 320 pixels.
5 | 1fr: The second column takes up the remaining available space (fractional unit), which means it will grow to fill the remaining area of the container.
6 |
--------------------------------------------------------------------------------
/.github/workflows/code_quality.yaml:
--------------------------------------------------------------------------------
1 | name: Code quality
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | jobs:
8 | quality:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v4
13 | - name: Setup Biome
14 | uses: biomejs/setup-biome@v2
15 | with:
16 | version: latest
17 | - name: Run Biome
18 | run: biome ci .
19 |
--------------------------------------------------------------------------------
/app/hooks/use-debounce.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | export function useDebounce(value: T, delay?: number): T {
4 | const [debouncedValue, setDebouncedValue] = React.useState(value)
5 |
6 | React.useEffect(() => {
7 | const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500)
8 |
9 | return () => {
10 | clearTimeout(timer)
11 | }
12 | }, [value, delay])
13 |
14 | return debouncedValue
15 | }
--------------------------------------------------------------------------------
/app/@types/category.d.ts:
--------------------------------------------------------------------------------
1 | export interface Category {
2 | id: string;
3 | title: string;
4 | description: string;
5 | minimumIndivudualContribution: number;
6 | createAt: Date;
7 | deletedAt: Date;
8 | createdBy: string;
9 | isPublished: boolean;
10 | endDate: Date;
11 | }
12 |
13 | export interface ListableCategory {
14 | id: string;
15 | name: string;
16 | description: string;
17 | finishedDate: string;
18 | }
19 |
--------------------------------------------------------------------------------
/app/data/contributors/contributors-summary.ts:
--------------------------------------------------------------------------------
1 | import { ContributorsDetailsProps } from '@/@types/contributors';
2 |
3 | export const ContributorsSummaryData: ContributorsDetailsProps[] = [
4 | { title: 'Male Contributors', value: '1.382', color: 'blue' },
5 | { title: 'Female Contributors', value: '194', color: 'green' },
6 | { title: 'Average Contributors', value: '283', color: 'red' },
7 | { title: 'Average Dependents', value: '35', color: 'orange' },
8 | ];
9 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { vitePlugin as remix } from "@remix-run/dev";
2 | import { defineConfig } from "vite";
3 | import envOnly from "vite-env-only";
4 | import tsconfigPaths from "vite-tsconfig-paths";
5 |
6 | export default defineConfig({
7 | plugins: [
8 | envOnly(),
9 | tsconfigPaths(),
10 | remix({
11 | future: {
12 | v3_fetcherPersist: true,
13 | v3_relativeSplatPath: true,
14 | v3_throwAbortReason: true,
15 | },
16 | }),
17 | ],
18 | });
19 |
--------------------------------------------------------------------------------
/app/data/db/seed.ts:
--------------------------------------------------------------------------------
1 | import { seedTasks } from "@/app/_lib/seeds";
2 |
3 | async function runSeed() {
4 | console.log("⏳ Running seed...");
5 |
6 | const start = Date.now();
7 |
8 | await seedTasks({ count: 100 });
9 |
10 | const end = Date.now();
11 |
12 | console.log(`✅ Seed completed in ${end - start}ms`);
13 |
14 | process.exit(0);
15 | }
16 |
17 | runSeed().catch((err) => {
18 | console.error("❌ Seed failed");
19 | console.error(err);
20 | process.exit(1);
21 | });
22 |
--------------------------------------------------------------------------------
/app/routes/contributors/components/progress-bar.tsx:
--------------------------------------------------------------------------------
1 | export const ProgressBarPrototype = () => {
2 | return (
3 |
4 |
5 |
6 |
7 |
8 |
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/app/routes/_index/route.tsx:
--------------------------------------------------------------------------------
1 | import MainLayout from '@/components/layouts/MainLayout';
2 | import { MetaFunction } from '@remix-run/react';
3 | import Dashboard from './components/dashboard';
4 |
5 | export const meta: MetaFunction = () => {
6 | return [
7 | { title: 'Welcome to FamCon' },
8 | { name: 'description', content: 'Welcome to Family Contributor!' },
9 | ];
10 | };
11 |
12 | export default function Index() {
13 | return (
14 |
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/app/components/global-pending-indicator.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigation } from "@remix-run/react";
2 |
3 | import { cn } from "@/lib/styles";
4 |
5 | export function GlobalPendingIndicator() {
6 | const navigation = useNavigation();
7 | const pending = navigation.state !== "idle";
8 |
9 | return (
10 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/app/data/db/migrate.ts:
--------------------------------------------------------------------------------
1 | import { migrate } from "drizzle-orm/postgres-js/migrator";
2 |
3 | import { db } from ".";
4 |
5 | export async function runMigrate() {
6 | console.log("⏳ Running migrations...");
7 |
8 | const start = Date.now();
9 |
10 | await migrate(db, { migrationsFolder: "drizzle" });
11 |
12 | const end = Date.now();
13 |
14 | console.log(`✅ Migrations completed in ${end - start}ms`);
15 |
16 | process.exit(0);
17 | }
18 |
19 | runMigrate().catch((err) => {
20 | console.error("❌ Migration failed");
21 | console.error(err);
22 | process.exit(1);
23 | });
24 |
--------------------------------------------------------------------------------
/app/routes/configure/components/columns.tsx:
--------------------------------------------------------------------------------
1 | import { ListableCategory } from '@/@types/category';
2 | import { ColumnDef } from '@tanstack/react-table';
3 |
4 | export const categoryColumns: ColumnDef[] = [
5 | {
6 | accessorKey: 'id',
7 | header: 'Id',
8 | },
9 | {
10 | accessorKey: 'name',
11 | header: 'Name',
12 | },
13 | {
14 | accessorKey: 'description',
15 | header: 'Description',
16 | },
17 | {
18 | accessorKey: 'finishedDate',
19 | header: 'Finished Date',
20 | },
21 | ];
22 |
--------------------------------------------------------------------------------
/app/lib/seed.ts:
--------------------------------------------------------------------------------
1 | // import { db } from "@/db";
2 | // import { tasks, type Task } from "@/db/schema";
3 |
4 | // import { generateRandomTask } from "./utils";
5 |
6 | // export async function seedTasks(input: { count: number }) {
7 | // const count = input.count ?? 100;
8 |
9 | // try {
10 | // const allTasks: Task[] = [];
11 |
12 | // for (let i = 0; i < count; i++) {
13 | // allTasks.push(generateRandomTask());
14 | // }
15 |
16 | // await db.delete(tasks);
17 |
18 | // console.log("📝 Inserting tasks", allTasks.length);
19 |
20 | // await db.insert(tasks).values(allTasks);
21 | // } catch (err) {
22 | // console.error(err);
23 | // }
24 | // }
25 |
--------------------------------------------------------------------------------
/app/@types/index.d.ts:
--------------------------------------------------------------------------------
1 | import { LucideIcon } from 'lucide-react';
2 |
3 | export interface Identifiable {
4 | id: string;
5 | }
6 |
7 | interface NavItemProps {
8 | label: string;
9 | icon: LucideIcon;
10 | href: string;
11 | children?: NavItemProps[];
12 | }
13 |
14 | export interface ICrudService<
15 | TListable extends Identifiable,
16 | TCreateUpdate extends Identifiable,
17 | TModel extends Identifiable
18 | > {
19 | getAll(): Promise;
20 | getById(id: string): Promise;
21 | create(item: Omit): Promise;
22 | update(id: string, item: Partial): Promise;
23 | delete(id: string): Promise;
24 | }
25 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["**/*.ts", "**/*.tsx", "server.js"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "types": ["@remix-run/node", "node", "vite/client"],
6 | "isolatedModules": true,
7 | "esModuleInterop": true,
8 | "jsx": "react-jsx",
9 | "module": "ESNext",
10 | "moduleResolution": "Bundler",
11 | "resolveJsonModule": true,
12 | "target": "ES2022",
13 | "strict": true,
14 | "allowJs": true,
15 | "skipLibCheck": true,
16 | "forceConsistentCasingInFileNames": true,
17 | "baseUrl": ".",
18 | "paths": {
19 | "@/*": ["./app/*"]
20 | },
21 |
22 | // Vite takes care of building everything, not tsc.
23 | "noEmit": true
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/app/lib/handle-error.ts:
--------------------------------------------------------------------------------
1 | import { isRedirectError } from "next/dist/client/components/redirect";
2 | import { toast } from "sonner";
3 | import { z } from "zod";
4 |
5 | export function getErrorMessage(err: unknown) {
6 | const unknownError = "Something went wrong, please try again later.";
7 |
8 | if (err instanceof z.ZodError) {
9 | const errors = err.issues.map((issue) => {
10 | return issue.message;
11 | });
12 | return errors.join("\n");
13 | } else if (err instanceof Error) {
14 | return err.message;
15 | } else if (isRedirectError(err)) {
16 | throw err;
17 | } else {
18 | return unknownError;
19 | }
20 | }
21 |
22 | export function showErrorToast(err: unknown) {
23 | const errorMessage = getErrorMessage(err);
24 | return toast.error(errorMessage);
25 | }
26 |
--------------------------------------------------------------------------------
/app/routes/contributors.$id.edit/route.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunction, json } from '@remix-run/node';
2 | import { useLoaderData } from '@remix-run/react';
3 |
4 | // -> Loaders get called when the route is hit
5 | // from the server side
6 | export const loader: LoaderFunction = async ({ params }) => {
7 | // Fetch data from the server
8 | const userId = params.id as string;
9 |
10 | return json({ userId });
11 | };
12 |
13 | // -> Actions get called when a form is submitted
14 | // from the server side
15 |
16 | // -> Default components get rendered AFTER the
17 | // page is loaded on the client side
18 | export default function ContributorEdit() {
19 | const { userId } = useLoaderData();
20 | // Implement edit form
21 | return Edit Contributor {userId}
;
22 | }
23 |
--------------------------------------------------------------------------------
/app/routes/contributors/components/contributor-summary-card.tsx:
--------------------------------------------------------------------------------
1 | import { Card } from '@/components/ui/card';
2 | import { AttendeeHeatMap } from './attendee-heat-map';
3 | import { ContributorDropdown } from './contributor-dropdown';
4 |
5 | export const ContributorSummaryCard = () => {
6 | return (
7 |
8 |
9 |
10 |
11 | Total Contributors
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/app/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/styles"
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/app/routes/inventory.tsx:
--------------------------------------------------------------------------------
1 | import MainLayout from "@/components/layouts/MainLayout";
2 | import { Button } from "@/components/ui/button";
3 |
4 | export default function Inventory() {
5 | return (
6 |
7 |
11 |
12 |
13 | You have no products Inventory
14 |
15 |
16 | You can start selling as soon as you add a product.
17 |
18 |
Add Product
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/app/data/db/utils.ts:
--------------------------------------------------------------------------------
1 | import { pgTableCreator } from "drizzle-orm/pg-core";
2 |
3 | import { databasePrefix } from "@/lib/constants";
4 |
5 | /**
6 | * This lets us use the multi-project schema feature of Drizzle ORM. So the same
7 | * database instance can be used for multiple projects.
8 | *
9 | * @see https://orm.drizzle.team/docs/goodies#multi-project-schema
10 | */
11 | export const pgTable = pgTableCreator((name) => `${databasePrefix}_${name}`);
12 |
13 | // @see https://gist.github.com/rphlmr/0d1722a794ed5a16da0fdf6652902b15
14 |
15 | export function takeFirst(items: T[]) {
16 | return items.at(0);
17 | }
18 |
19 | export function takeFirstOrThrow(items: T[]) {
20 | const first = takeFirst(items);
21 |
22 | if (!first) {
23 | throw new Error("First item not found");
24 | }
25 |
26 | return first;
27 | }
28 |
--------------------------------------------------------------------------------
/app/@types/contributors.d.ts:
--------------------------------------------------------------------------------
1 | import { Identifiable } from '.';
2 |
3 | export interface TPartialContributor extends Identifiable {
4 | id: string;
5 | firstName: string;
6 | lastName: string;
7 | email: string;
8 | gender: string;
9 | dependent: boolean;
10 | contributionAmount: number;
11 | contributiionMethod: 'monthly' | 'annual';
12 | dateJoined: Date;
13 | }
14 |
15 | export interface TContributor extends Identifiable {
16 | id: string;
17 | firstName: string;
18 | lastName: string;
19 | email: string;
20 | gender: string;
21 | dependent: boolean;
22 | contributionAmount: number;
23 | contributiionMethod: 'monthly' | 'annual';
24 | dateJoined: Date;
25 | }
26 |
27 | interface ContributorsDetailsProps {
28 | title: string;
29 | value: string;
30 | color: string;
31 | }
32 |
--------------------------------------------------------------------------------
/app/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/styles"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/app/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/styles";
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | },
22 | );
23 | Input.displayName = "Input";
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/app/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
3 |
4 | import { cn } from "@/lib/styles"
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(
10 | (
11 | { className, orientation = "horizontal", decorative = true, ...props },
12 | ref
13 | ) => (
14 |
25 | )
26 | )
27 | Separator.displayName = SeparatorPrimitive.Root.displayName
28 |
29 | export { Separator }
30 |
--------------------------------------------------------------------------------
/app/routes/contributors/components/attendee-heat-map.tsx:
--------------------------------------------------------------------------------
1 | import { ContributorsDetails } from './detail';
2 | import { ProgressBarPrototype } from './progress-bar';
3 |
4 | export const AttendeeHeatMap = () => {
5 | return (
6 |
7 |
8 |
9 | 1.894
10 |
11 |
12 | Contributors
13 |
14 |
15 |
16 |
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | app = "remix-shadcn"
2 | kill_signal = "SIGINT"
3 | kill_timeout = 5
4 | processes = []
5 |
6 | [env]
7 | PORT = "3000"
8 |
9 | [mounts]
10 | source = "remix_shadcn_data"
11 | destination = "/data"
12 |
13 | [[services]]
14 | internal_port = 3000
15 | processes = ["app"]
16 | protocol = "tcp"
17 | script_checks = []
18 |
19 | [services.concurrency]
20 | hard_limit = 50
21 | soft_limit = 40
22 | type = "connections"
23 |
24 | [[services.ports]]
25 | force_https = true
26 | handlers = ["http"]
27 | port = 80
28 |
29 | [[services.ports]]
30 | handlers = ["tls", "http"]
31 | port = 443
32 |
33 | [[services.tcp_checks]]
34 | grace_period = "60s"
35 | interval = "15s"
36 | restart_limit = 6
37 | timeout = "2s"
38 |
39 | [[services.http_checks]]
40 | grace_period = "5s"
41 | headers = {}
42 | interval = 10_000
43 | method = "get"
44 | path = "/healthcheck"
45 | protocol = "http"
46 | timeout = 2_000
47 | tls_skip_verify = false
48 |
--------------------------------------------------------------------------------
/app/components/atoms/summary-card.tsx:
--------------------------------------------------------------------------------
1 | import type { LucideIcon } from "lucide-react";
2 | import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
3 |
4 | interface SummaryCardProps {
5 | title: string;
6 | icon: LucideIcon;
7 | mainValue: string;
8 | details: string;
9 | }
10 |
11 | export const SummaryCard = ({
12 | title,
13 | icon: Icon,
14 | mainValue,
15 | details,
16 | }: SummaryCardProps) => {
17 | return (
18 |
19 |
20 | {title}
21 |
22 |
23 |
24 | {mainValue}
25 | {details}
26 |
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/app/components/shell.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/styles"
5 |
6 | const shellVariants = cva("grid items-center gap-8 pb-8 pt-6 md:py-8", {
7 | variants: {
8 | variant: {
9 | default: "container",
10 | sidebar: "",
11 | centered: "container flex h-dvh max-w-2xl flex-col justify-center py-16",
12 | markdown: "container max-w-3xl py-8 md:py-10 lg:py-10",
13 | },
14 | },
15 | defaultVariants: {
16 | variant: "default",
17 | },
18 | })
19 |
20 | interface ShellProps
21 | extends React.HTMLAttributes,
22 | VariantProps {
23 | as?: React.ElementType
24 | }
25 |
26 | function Shell({
27 | className,
28 | as: Comp = "section",
29 | variant,
30 | ...props
31 | }: ShellProps) {
32 | return (
33 |
34 | )
35 | }
36 |
37 | export { Shell, shellVariants }
--------------------------------------------------------------------------------
/app/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
3 | import { CheckIcon } from "@radix-ui/react-icons"
4 |
5 | import { cn } from "@/lib/styles"
6 |
7 | const Checkbox = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 |
22 |
23 |
24 |
25 | ))
26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
27 |
28 | export { Checkbox }
29 |
--------------------------------------------------------------------------------
/app/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export function formatDate(
9 | date: Date | string | number,
10 | opts: Intl.DateTimeFormatOptions = {},
11 | ) {
12 | return new Intl.DateTimeFormat("en-US", {
13 | month: opts.month ?? "long",
14 | day: opts.day ?? "numeric",
15 | year: opts.year ?? "numeric",
16 | ...opts,
17 | }).format(new Date(date));
18 | }
19 |
20 | /**
21 | * Stole this from the @radix-ui/primitive
22 | * @see https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx
23 | */
24 | export function composeEventHandlers(
25 | originalEventHandler?: (event: E) => void,
26 | ourEventHandler?: (event: E) => void,
27 | { checkForDefaultPrevented = true } = {},
28 | ) {
29 | return function handleEvent(event: E) {
30 | originalEventHandler?.(event);
31 |
32 | if (
33 | checkForDefaultPrevented === false ||
34 | !(event as unknown as Event).defaultPrevented
35 | ) {
36 | return ourEventHandler?.(event);
37 | }
38 | };
39 | }
40 |
--------------------------------------------------------------------------------
/app/lib/id.ts:
--------------------------------------------------------------------------------
1 | import { customAlphabet } from "nanoid"
2 |
3 | const prefixes = {
4 | task: "tsk",
5 | }
6 |
7 | interface GenerateIdOptions {
8 | /**
9 | * The length of the generated ID.
10 | * @default 16
11 | * @example 16 => "abc123def456ghi7"
12 | * */
13 | length?: number
14 | /**
15 | * The separator to use between the prefix and the generated ID.
16 | * @default "_"
17 | * @example "_" => "str_abc123"
18 | * */
19 | separator?: string
20 | }
21 |
22 | /**
23 | * Generates a unique ID with a given prefix.
24 | * @param prefix The prefix to use for the generated ID.
25 | * @param options The options for generating the ID.
26 | * @example
27 | * generateId("store") => "str_abc123def456"
28 | * generateId("store", { length: 8 }) => "str_abc123d"
29 | * generateId("store", { separator: "-" }) => "str-abc123def456"
30 | */
31 | export function generateId(
32 | prefix?: keyof typeof prefixes,
33 | { length = 12, separator = "_" }: GenerateIdOptions = {}
34 | ) {
35 | const id = customAlphabet(
36 | "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
37 | length
38 | )()
39 | return prefix ? `${prefixes[prefix]}${separator}${id}` : id
40 | }
--------------------------------------------------------------------------------
/app/services/contributors/contributor-service.ts:
--------------------------------------------------------------------------------
1 | import { ICrudService } from '@/@types';
2 | import { TContributor, TPartialContributor } from '@/@types/contributors';
3 | import { contributors } from '@/data/contributors/data';
4 |
5 | export class ContributorService
6 | implements ICrudService
7 | {
8 | async getAll(): Promise {
9 | // Implementation
10 | await new Promise((resolve) => setTimeout(resolve, 1000));
11 | return contributors;
12 | }
13 |
14 | async getById(id: string): Promise {
15 | // Implementation
16 | throw new Error('Method not implemented.');
17 | }
18 |
19 | async create(item: Omit): Promise {
20 | // Implementation
21 | throw new Error('Method not implemented.');
22 | }
23 |
24 | async update(
25 | id: string,
26 | item: Partial
27 | ): Promise {
28 | // Implementation
29 | throw new Error('Method not implemented.');
30 | }
31 |
32 | async delete(id: string): Promise {
33 | // Implementation
34 | throw new Error('Method not implemented.');
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/lib/validations.ts:
--------------------------------------------------------------------------------
1 | import { tasks } from "@/db/schema";
2 | import * as z from "zod";
3 |
4 | export const searchParamsSchema = z.object({
5 | page: z.coerce.number().default(1),
6 | per_page: z.coerce.number().default(10),
7 | sort: z.string().optional(),
8 | title: z.string().optional(),
9 | status: z.string().optional(),
10 | priority: z.string().optional(),
11 | from: z.string().optional(),
12 | to: z.string().optional(),
13 | operator: z.enum(["and", "or"]).optional(),
14 | });
15 |
16 | export const getTasksSchema = searchParamsSchema;
17 |
18 | export type GetTasksSchema = z.infer;
19 |
20 | export const createTaskSchema = z.object({
21 | title: z.string(),
22 | label: z.enum(tasks.label.enumValues),
23 | status: z.enum(tasks.status.enumValues),
24 | priority: z.enum(tasks.priority.enumValues),
25 | });
26 |
27 | export type CreateTaskSchema = z.infer;
28 |
29 | export const updateTaskSchema = z.object({
30 | title: z.string().optional(),
31 | label: z.enum(tasks.label.enumValues).optional(),
32 | status: z.enum(tasks.status.enumValues).optional(),
33 | priority: z.enum(tasks.priority.enumValues).optional(),
34 | });
35 |
36 | export type UpdateTaskSchema = z.infer;
37 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # base node image
2 | FROM node:20-bullseye-slim as base
3 |
4 | # set for base and all layer that inherit from it
5 | ENV NODE_ENV production
6 |
7 | # Install all node_modules, including dev
8 | FROM base as deps
9 |
10 | WORKDIR /remixapp
11 |
12 | ADD package.json package-lock.json ./
13 | RUN npm install --include=dev
14 |
15 | # Setup production node_modules
16 | FROM base as production-deps
17 |
18 | WORKDIR /remixapp
19 |
20 | COPY --from=deps /remixapp/node_modules /remixapp/node_modules
21 | ADD package.json package-lock.json ./
22 | RUN npm prune --omit=dev
23 |
24 | # Build the app
25 | FROM base as build
26 |
27 | WORKDIR /remixapp
28 |
29 | COPY --from=deps /remixapp/node_modules /remixapp/node_modules
30 | ADD package.json package-lock.json postcss.config.js tailwind.config.cjs tsconfig.json vite.config.ts ./
31 | ADD app/ app/
32 | ADD public/ public/
33 |
34 | RUN npm run build
35 |
36 | # Finally, build the production image with minimal footprint
37 | FROM base
38 |
39 | WORKDIR /remixapp
40 |
41 | COPY --from=production-deps /remixapp/node_modules /remixapp/node_modules
42 | COPY --from=build /remixapp/build /remixapp/build
43 | COPY --from=build /remixapp/package.json /remixapp/package.json
44 |
45 | ADD server.js ./
46 |
47 | CMD ["npm", "start"]
48 |
--------------------------------------------------------------------------------
/app/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/styles"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/app/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip";
3 |
4 | import { cn } from "@/lib/styles";
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider;
7 |
8 | const Tooltip = TooltipPrimitive.Root;
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger;
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(
16 | (
17 | { className, sideOffset = 4, align = "start", side = "right", ...props },
18 | ref,
19 | ) => (
20 |
31 | ),
32 | );
33 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
34 |
35 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
36 |
--------------------------------------------------------------------------------
/app/routes/contributors/components/detail.tsx:
--------------------------------------------------------------------------------
1 | import { ContributorsSummaryData } from '@/data/contributors/contributors-summary';
2 |
3 | export const ContributorsDetails = () => {
4 | return (
5 |
6 | {ContributorsSummaryData.map((item) => (
7 |
11 |
12 |
15 |
16 | {item.title}
17 |
18 |
19 |
20 |
21 | {item.value}
22 |
23 |
24 | Contributors
25 |
26 |
27 |
28 | ))}
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/app/data/db/schema.ts:
--------------------------------------------------------------------------------
1 | import { pgTable } from "@/db/utils"
2 | import { sql } from "drizzle-orm"
3 | import { pgEnum, timestamp, varchar } from "drizzle-orm/pg-core"
4 |
5 | import { databasePrefix } from "@/lib/constants"
6 | import { generateId } from "@/lib/id"
7 |
8 | export const statusEnum = pgEnum(`${databasePrefix}_status`, [
9 | "todo",
10 | "in-progress",
11 | "done",
12 | "canceled",
13 | ])
14 |
15 | export const labelEnum = pgEnum(`${databasePrefix}_label`, [
16 | "bug",
17 | "feature",
18 | "enhancement",
19 | "documentation",
20 | ])
21 |
22 | export const priorityEnum = pgEnum(`${databasePrefix}_priority`, [
23 | "low",
24 | "medium",
25 | "high",
26 | ])
27 |
28 | export const tasks = pgTable("tasks", {
29 | id: varchar("id", { length: 30 })
30 | .$defaultFn(() => generateId())
31 | .primaryKey(),
32 | code: varchar("code", { length: 256 }).notNull().unique(),
33 | title: varchar("title", { length: 256 }),
34 | status: statusEnum("status").notNull().default("todo"),
35 | label: labelEnum("label").notNull().default("bug"),
36 | priority: priorityEnum("priority").notNull().default("low"),
37 | createdAt: timestamp("created_at").defaultNow().notNull(),
38 | updatedAt: timestamp("updated_at")
39 | .default(sql`current_timestamp`)
40 | .$onUpdate(() => new Date()),
41 | })
42 |
43 | export type Task = typeof tasks.$inferSelect
44 | export type NewTask = typeof tasks.$inferInsert
--------------------------------------------------------------------------------
/app/services/category/category-service.ts:
--------------------------------------------------------------------------------
1 | import { ICrudService } from '@/@types';
2 | import { Category, ListableCategory } from '@/@types/category';
3 |
4 | export class CategoryService
5 | implements ICrudService
6 | {
7 | async getAll(): Promise {
8 | await new Promise((resolve) => setTimeout(resolve, 1000));
9 | return [
10 | {
11 | id: '1',
12 | name: 'Category 1',
13 | description: 'Description 1',
14 | finishedDate: '2021-01-01',
15 | },
16 | {
17 | id: '2',
18 | name: 'Category 2',
19 | description: 'Description 2',
20 | finishedDate: '2021-01-02',
21 | },
22 | ];
23 | }
24 |
25 | async getById(id: string): Promise {
26 | // Implementation
27 | throw new Error('Method not implemented.');
28 | }
29 |
30 | async create(item: Omit): Promise {
31 | // Implementation
32 | throw new Error('Method not implemented.');
33 | }
34 |
35 | async update(id: string, item: Partial): Promise {
36 | // Implementation
37 | throw new Error('Method not implemented.');
38 | }
39 |
40 | async delete(id: string): Promise {
41 | // Implementation
42 | throw new Error('Method not implemented.');
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/routes/contributors/components/contributor-dropdown.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import {
3 | DropdownMenu,
4 | DropdownMenuContent,
5 | DropdownMenuLabel,
6 | DropdownMenuRadioGroup,
7 | DropdownMenuRadioItem,
8 | DropdownMenuSeparator,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 | import { ChevronDownIcon } from "lucide-react";
12 | import { useState } from "react";
13 |
14 | export const ContributorDropdown = () => {
15 | const [position, setPosition] = useState("bottom");
16 |
17 | return (
18 |
19 |
20 |
24 | Contributor Type
25 |
26 |
27 |
28 |
29 | Panel Position
30 |
31 |
32 | Dependent
33 | Student
34 | Couple
35 |
36 |
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/app/components/atoms/kbd.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const kbdVariants = cva(
7 | "select-none rounded border px-1.5 py-px font-mono text-[0.7rem] font-normal shadow-sm disabled:opacity-50",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-accent text-accent-foreground",
12 | outline: "bg-background text-foreground",
13 | },
14 | },
15 | defaultVariants: {
16 | variant: "default",
17 | },
18 | }
19 | )
20 |
21 | export interface KbdProps
22 | extends React.ComponentPropsWithoutRef<"kbd">,
23 | VariantProps {
24 | /**
25 | * The title of the `abbr` element inside the `kbd` element.
26 | * @default undefined
27 | * @type string | undefined
28 | * @example title="Command"
29 | */
30 | abbrTitle?: string
31 | }
32 |
33 | const Kbd = React.forwardRef(
34 | ({ abbrTitle, children, className, variant, ...props }, ref) => {
35 | return (
36 |
41 | {abbrTitle ? (
42 |
43 | {children}
44 |
45 | ) : (
46 | children
47 | )}
48 |
49 | )
50 | }
51 | )
52 | Kbd.displayName = "Kbd"
53 |
54 | export { Kbd }
--------------------------------------------------------------------------------
/app/components/atoms/nav-item.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { NavLink } from "@remix-run/react";
3 | import type { LucideIcon } from "lucide-react";
4 | import {
5 | Tooltip,
6 | TooltipContent,
7 | TooltipProvider,
8 | TooltipTrigger,
9 | } from "@/components/ui/tooltip";
10 |
11 | interface NavItemProps {
12 | label?: string;
13 | href: string;
14 | icon: LucideIcon;
15 | minimal: boolean;
16 | }
17 |
18 | export const NavItem = ({
19 | label,
20 | href,
21 | icon: Icon,
22 |
23 | minimal,
24 | }: NavItemProps) => {
25 | return (
26 |
27 |
28 |
31 | cn(
32 | "flex lg:text-lg items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary",
33 | isActive
34 | ? "bg-muted text-muted-foreground"
35 | : "text-muted-foreground",
36 | minimal ? "flex items-center justify-center" : "",
37 | )
38 | }
39 | >
40 |
41 |
42 |
43 | {
44 |
45 | {label}
46 |
47 | }
48 | {minimal ? "" : {label}
}
49 |
50 |
51 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/app/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as PopoverPrimitive from "@radix-ui/react-popover";
2 | import * as React from "react";
3 |
4 | import { cn } from "@/lib/styles";
5 |
6 | const Popover = PopoverPrimitive.Root;
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger;
9 |
10 | const PopoverAnchor = PopoverPrimitive.Anchor;
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(
16 | (
17 | { className, align = "start", side = "right", sideOffset = 4, ...props },
18 | ref,
19 | ) => (
20 |
21 |
32 |
33 | ),
34 | );
35 |
36 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
37 |
38 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
39 |
--------------------------------------------------------------------------------
/app/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TogglePrimitive from "@radix-ui/react-toggle"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/styles"
6 |
7 | const toggleVariants = cva(
8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-transparent",
13 | outline:
14 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
15 | },
16 | size: {
17 | default: "h-9 px-3",
18 | sm: "h-8 px-2",
19 | lg: "h-10 px-3",
20 | },
21 | },
22 | defaultVariants: {
23 | variant: "default",
24 | size: "default",
25 | },
26 | }
27 | )
28 |
29 | const Toggle = React.forwardRef<
30 | React.ElementRef,
31 | React.ComponentPropsWithoutRef &
32 | VariantProps
33 | >(({ className, variant, size, ...props }, ref) => (
34 |
39 | ))
40 |
41 | Toggle.displayName = TogglePrimitive.Root.displayName
42 |
43 | export { Toggle, toggleVariants }
44 |
--------------------------------------------------------------------------------
/app/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as AvatarPrimitive from "@radix-ui/react-avatar";
2 | import * as React from "react";
3 |
4 | import { cn } from "@/lib/styles";
5 |
6 | const Avatar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ));
19 | Avatar.displayName = AvatarPrimitive.Root.displayName;
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ));
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ));
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
47 |
48 | export { Avatar, AvatarImage, AvatarFallback };
49 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | import { createRequestHandler } from "@remix-run/express";
2 | import { installGlobals } from "@remix-run/node";
3 | import compression from "compression";
4 | import express from "express";
5 | import morgan from "morgan";
6 |
7 | installGlobals();
8 |
9 | const viteDevServer =
10 | process.env.NODE_ENV === "production"
11 | ? undefined
12 | : await import("vite").then((vite) =>
13 | vite.createServer({
14 | server: { middlewareMode: true },
15 | }),
16 | );
17 |
18 | // Create a request handler for Remix
19 | const remixHandler = createRequestHandler({
20 | build: viteDevServer
21 | ? () => viteDevServer.ssrLoadModule("virtual:remix/server-build")
22 | : () => import("./build/server/index.js"),
23 | });
24 |
25 | const app = express();
26 |
27 | app.use(compression());
28 |
29 | // http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
30 | app.disable("x-powered-by");
31 |
32 | // handle asset requests
33 | if (viteDevServer) {
34 | app.use(viteDevServer.middlewares);
35 | } else {
36 | // Vite fingerprints its assets so we can cache forever.
37 | app.use(
38 | "/assets",
39 | express.static("build/client/assets", { immutable: true, maxAge: "1y" }),
40 | );
41 | }
42 |
43 | // Everything else (like favicon.ico) is cached for an hour. You may want to be
44 | // more aggressive with this caching.
45 | app.use(express.static("build/client", { maxAge: "1h" }));
46 |
47 | app.use(morgan("tiny"));
48 |
49 | // handle SSR requests
50 | app.all("*", remixHandler);
51 |
52 | const port = process.env.PORT || 3000;
53 | app.listen(port, () =>
54 | console.log(`Express server listening at http://localhost:${port}`),
55 | );
56 |
--------------------------------------------------------------------------------
/app/config/nav.ts:
--------------------------------------------------------------------------------
1 | import type { NavItemProps } from '@/@types';
2 | import {
3 | BellRing,
4 | Book,
5 | BookOpen,
6 | Bug,
7 | CircleHelp,
8 | CreditCard,
9 | LayoutDashboard,
10 | Settings,
11 | User,
12 | Users,
13 | } from 'lucide-react';
14 |
15 | export const navItems: NavItemProps[] = [
16 | { label: 'Dashboard', icon: LayoutDashboard, href: '/' },
17 | { label: 'Transact', icon: CreditCard, href: '/transact' },
18 | { label: 'Contributions', icon: User, href: '/contributors' },
19 | {
20 | label: 'User Management',
21 | icon: Users,
22 | href: '#',
23 | children: [
24 | { label: 'Users', icon: Users, href: '/user-management/users' },
25 | { label: 'Roles', icon: Users, href: '/user-management/roles' },
26 | ],
27 | },
28 | { label: 'Configurations', icon: Settings, href: '/configure' },
29 | {
30 | label: 'Reports',
31 | icon: Bug,
32 | href: '#',
33 | children: [
34 | {
35 | label: 'Individual Statement',
36 | icon: Bug,
37 | href: '/reports/individual-statement',
38 | },
39 | {
40 | label: 'Group Statement',
41 | icon: Bug,
42 | href: '/reports/group-statement',
43 | },
44 | { label: 'Receipts', icon: Bug, href: '/reports/receipts' },
45 | ],
46 | },
47 | ];
48 |
49 | export const secondaryNavItems: NavItemProps[] = [
50 | { label: 'Reminder', icon: BellRing, href: '/reminder' },
51 | { label: 'About', icon: BookOpen, href: '/about' },
52 | { label: 'Help', icon: CircleHelp, href: '/help' },
53 | ];
54 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Links,
3 | Meta,
4 | Outlet,
5 | Scripts,
6 | ScrollRestoration,
7 | isRouteErrorResponse,
8 | useRouteError,
9 | } from "@remix-run/react";
10 |
11 | import { GlobalPendingIndicator } from "@/components/global-pending-indicator";
12 | import {
13 | ThemeSwitcherSafeHTML,
14 | ThemeSwitcherScript,
15 | } from "@/components/theme-switcher";
16 |
17 | import "./globals.css";
18 |
19 | function App({ children }: { children: React.ReactNode }) {
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | {/* */}
32 | {children}
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | export default function Root() {
41 | return (
42 |
43 |
44 |
45 | );
46 | }
47 |
48 | export function ErrorBoundary() {
49 | const error = useRouteError();
50 | let status = 500;
51 | let message = "An unexpected error occurred.";
52 | if (isRouteErrorResponse(error)) {
53 | status = error.status;
54 | switch (error.status) {
55 | case 404:
56 | message = "Page Not Found";
57 | break;
58 | }
59 | } else {
60 | console.error(error);
61 | }
62 |
63 | return (
64 |
65 |
66 |
{status}
67 |
{message}
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/app/config/data-table.ts:
--------------------------------------------------------------------------------
1 | import { MixIcon, SquareIcon } from "@radix-ui/react-icons"
2 |
3 | export type DataTableConfig = typeof dataTableConfig
4 |
5 | export const dataTableConfig = {
6 | comparisonOperators: [
7 | { label: "Contains", value: "ilike" as const },
8 | { label: "Does not contain", value: "notIlike" as const },
9 | { label: "Is", value: "eq" as const },
10 | { label: "Is not", value: "notEq" as const },
11 | { label: "Starts with", value: "startsWith" as const },
12 | { label: "Ends with", value: "endsWith" as const },
13 | { label: "Is empty", value: "isNull" as const },
14 | { label: "Is not empty", value: "isNotNull" as const },
15 | ],
16 | selectableOperators: [
17 | { label: "Is", value: "eq" as const },
18 | { label: "Is not", value: "notEq" as const },
19 | { label: "Is empty", value: "isNull" as const },
20 | { label: "Is not empty", value: "isNotNull" as const },
21 | ],
22 | logicalOperators: [
23 | {
24 | label: "And",
25 | value: "and" as const,
26 | description: "All conditions must be met",
27 | },
28 | {
29 | label: "Or",
30 | value: "or" as const,
31 | description: "At least one condition must be met",
32 | },
33 | ],
34 | featureFlags: [
35 | {
36 | label: "Advanced filter",
37 | value: "advancedFilter" as const,
38 | icon: MixIcon,
39 | tooltipTitle: "Toggle advanced filter",
40 | tooltipDescription: "A notion like query builder to filter rows.",
41 | },
42 | {
43 | label: "Floating bar",
44 | value: "floatingBar" as const,
45 | icon: SquareIcon,
46 | tooltipTitle: "Toggle floating bar",
47 | tooltipDescription: "A floating bar that sticks to the top of the table.",
48 | },
49 | ],
50 | }
--------------------------------------------------------------------------------
/app/lib/filter-column.ts:
--------------------------------------------------------------------------------
1 | import {
2 | eq,
3 | ilike,
4 | inArray,
5 | isNotNull,
6 | isNull,
7 | not,
8 | notLike,
9 | type Column,
10 | type ColumnBaseConfig,
11 | type ColumnDataType,
12 | } from "drizzle-orm";
13 |
14 | import { type DataTableConfig } from "@/config/data-table";
15 |
16 | export function filterColumn({
17 | column,
18 | value,
19 | isSelectable,
20 | }: {
21 | column: Column, object, object>;
22 | value: string;
23 | isSelectable?: boolean;
24 | }) {
25 | const [filterValue, filterOperator] = (value?.split("~").filter(Boolean) ??
26 | []) as [
27 | string,
28 | DataTableConfig["comparisonOperators"][number]["value"] | undefined,
29 | ];
30 |
31 | if (!filterValue) return;
32 |
33 | if (isSelectable) {
34 | switch (filterOperator) {
35 | case "eq":
36 | return inArray(column, filterValue?.split(".").filter(Boolean) ?? []);
37 | case "notEq":
38 | return not(
39 | inArray(column, filterValue?.split(".").filter(Boolean) ?? []),
40 | );
41 | case "isNull":
42 | return isNull(column);
43 | case "isNotNull":
44 | return isNotNull(column);
45 | default:
46 | return inArray(column, filterValue?.split(".") ?? []);
47 | }
48 | }
49 |
50 | switch (filterOperator) {
51 | case "ilike":
52 | return ilike(column, `%${filterValue}%`);
53 | case "notIlike":
54 | return notLike(column, `%${filterValue}%`);
55 | case "startsWith":
56 | return ilike(column, `${filterValue}%`);
57 | case "endsWith":
58 | return ilike(column, `%${filterValue}`);
59 | case "eq":
60 | return eq(column, filterValue);
61 | case "notEq":
62 | return not(eq(column, filterValue));
63 | case "isNull":
64 | return isNull(column);
65 | case "isNotNull":
66 | return isNotNull(column);
67 | default:
68 | return ilike(column, `%${filterValue}%`);
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/app/components/ui/toggle-group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
3 | import { VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/styles"
6 | import { toggleVariants } from "@/components/ui/toggle"
7 |
8 | const ToggleGroupContext = React.createContext<
9 | VariantProps
10 | >({
11 | size: "default",
12 | variant: "default",
13 | })
14 |
15 | const ToggleGroup = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef &
18 | VariantProps
19 | >(({ className, variant, size, children, ...props }, ref) => (
20 |
25 |
26 | {children}
27 |
28 |
29 | ))
30 |
31 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
32 |
33 | const ToggleGroupItem = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef &
36 | VariantProps
37 | >(({ className, children, variant, size, ...props }, ref) => {
38 | const context = React.useContext(ToggleGroupContext)
39 |
40 | return (
41 |
52 | {children}
53 |
54 | )
55 | })
56 |
57 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
58 |
59 | export { ToggleGroup, ToggleGroupItem }
60 |
--------------------------------------------------------------------------------
/app/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/styles"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center 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",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/app/lib/export.ts:
--------------------------------------------------------------------------------
1 | import { type Table } from "@tanstack/react-table"
2 |
3 | export function exportTableToCSV(
4 | /**
5 | * The table to export.
6 | * @type Table
7 | */
8 | table: Table,
9 | opts: {
10 | /**
11 | * The filename for the CSV file.
12 | * @default "table"
13 | * @example "tasks"
14 | */
15 | filename?: string
16 | /**
17 | * The columns to exclude from the CSV file.
18 | * @default []
19 | * @example ["select", "actions"]
20 | */
21 | excludeColumns?: (keyof TData | "select" | "actions")[]
22 |
23 | /**
24 | * Whether to export only the selected rows.
25 | * @default false
26 | */
27 | onlySelected?: boolean
28 | } = {}
29 | ): void {
30 | const { filename = "table", excludeColumns = [], onlySelected = false } = opts
31 |
32 | // Retrieve headers (column names)
33 | const headers = table
34 | .getAllLeafColumns()
35 | .map((column) => column.id)
36 | .filter((id) => !excludeColumns.includes(id))
37 |
38 | // Build CSV content
39 | const csvContent = [
40 | headers.join(","),
41 | ...(onlySelected
42 | ? table.getFilteredSelectedRowModel().rows
43 | : table.getRowModel().rows
44 | ).map((row) =>
45 | headers
46 | .map((header) => {
47 | const cellValue = row.getValue(header)
48 | // Handle values that might contain commas or newlines
49 | return typeof cellValue === "string"
50 | ? `"${cellValue.replace(/"/g, '""')}"`
51 | : cellValue
52 | })
53 | .join(",")
54 | ),
55 | ].join("\n")
56 |
57 | // Create a Blob with CSV content
58 | const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
59 |
60 | // Create a link and trigger the download
61 | const url = URL.createObjectURL(blob)
62 | const link = document.createElement("a")
63 | link.setAttribute("href", url)
64 | link.setAttribute("download", `${filename}.csv`)
65 | link.style.visibility = "hidden"
66 | document.body.appendChild(link)
67 | link.click()
68 | document.body.removeChild(link)
69 | }
70 |
--------------------------------------------------------------------------------
/app/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/styles";
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = "Card";
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = "CardHeader";
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ));
42 | CardTitle.displayName = "CardTitle";
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLParagraphElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ));
54 | CardDescription.displayName = "CardDescription";
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ));
62 | CardContent.displayName = "CardContent";
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ));
74 | CardFooter.displayName = "CardFooter";
75 |
76 | export {
77 | Card,
78 | CardHeader,
79 | CardFooter,
80 | CardTitle,
81 | CardDescription,
82 | CardContent,
83 | };
84 |
--------------------------------------------------------------------------------
/app/lib/xlsx.ts:
--------------------------------------------------------------------------------
1 | import { type Table } from "@tanstack/react-table";
2 |
3 | export function exportTableToCSV(
4 | /**
5 | * The table to export.
6 | * @type Table
7 | */
8 | table: Table,
9 | opts: {
10 | /**
11 | * The filename for the CSV file.
12 | * @default "table"
13 | * @example "tasks"
14 | */
15 | filename?: string;
16 | /**
17 | * The columns to exclude from the CSV file.
18 | * @default []
19 | * @example ["select", "actions"]
20 | */
21 | excludeColumns?: (keyof TData | "select" | "actions")[];
22 |
23 | /**
24 | * Whether to export only the selected rows.
25 | * @default false
26 | */
27 | onlySelected?: boolean;
28 | } = {},
29 | ): void {
30 | const {
31 | filename = "table",
32 | excludeColumns = [],
33 | onlySelected = false,
34 | } = opts;
35 |
36 | // Retrieve headers (column names)
37 | const headers = table
38 | .getAllLeafColumns()
39 | .map((column) => column.id)
40 | .filter((id) => !excludeColumns.includes(id));
41 |
42 | // Build CSV content
43 | const csvContent = [
44 | headers.join(","),
45 | ...(onlySelected
46 | ? table.getFilteredSelectedRowModel().rows
47 | : table.getRowModel().rows
48 | ).map((row) =>
49 | headers
50 | .map((header) => {
51 | const cellValue = row.getValue(header);
52 | // Handle values that might contain commas or newlines
53 | return typeof cellValue === "string"
54 | ? `"${cellValue.replace(/"/g, '""')}"`
55 | : cellValue;
56 | })
57 | .join(","),
58 | ),
59 | ].join("\n");
60 |
61 | // Create a Blob with CSV content
62 | const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
63 |
64 | // Create a link and trigger the download
65 | const url = URL.createObjectURL(blob);
66 | const link = document.createElement("a");
67 | link.setAttribute("href", url);
68 | link.setAttribute("download", `${filename}.csv`);
69 | link.style.visibility = "hidden";
70 | document.body.appendChild(link);
71 | link.click();
72 | document.body.removeChild(link);
73 | }
74 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import { PassThrough } from "node:stream";
2 |
3 | import type { AppLoadContext, EntryContext } from "@remix-run/node";
4 | import { createReadableStreamFromReadable } from "@remix-run/node";
5 | import { RemixServer } from "@remix-run/react";
6 | import { isbot } from "isbot";
7 | import { renderToPipeableStream } from "react-dom/server";
8 |
9 | const ABORT_DELAY = 5_000;
10 |
11 | export default function handleRequest(
12 | request: Request,
13 | responseStatusCode: number,
14 | responseHeaders: Headers,
15 | remixContext: EntryContext,
16 | loadContext: AppLoadContext,
17 | ) {
18 | const isBot = isbot(request.headers.get("user-agent"));
19 |
20 | let status = responseStatusCode;
21 | const headers = new Headers(responseHeaders);
22 | headers.set("Content-Type", "text/html; charset=utf-8");
23 |
24 | return new Promise((resolve, reject) => {
25 | let shellRendered = false;
26 | const { pipe, abort } = renderToPipeableStream(
27 | ,
32 | {
33 | onAllReady() {
34 | if (!isBot) return;
35 |
36 | resolve(
37 | new Response(
38 | createReadableStreamFromReadable(pipe(new PassThrough())),
39 | {
40 | headers,
41 | status,
42 | },
43 | ),
44 | );
45 | },
46 | onShellReady() {
47 | shellRendered = true;
48 |
49 | if (isBot) return;
50 |
51 | resolve(
52 | new Response(
53 | createReadableStreamFromReadable(pipe(new PassThrough())),
54 | {
55 | headers,
56 | status,
57 | },
58 | ),
59 | );
60 | },
61 | onShellError(error: unknown) {
62 | reject(error);
63 | },
64 | onError(error: unknown) {
65 | status = 500;
66 | // Log streaming rendering errors from inside the shell. Don't log
67 | // errors encountered during initial shell rendering since they'll
68 | // reject and get logged in handleDocumentRequest.
69 | if (shellRendered) {
70 | console.error(error);
71 | }
72 | },
73 | },
74 | );
75 |
76 | setTimeout(abort, ABORT_DELAY);
77 | });
78 | }
79 |
--------------------------------------------------------------------------------
/app/components/blocks/crud-list.tsx:
--------------------------------------------------------------------------------
1 | // /components/CrudList.tsx
2 |
3 | import React from 'react';
4 | import {
5 | Sheet,
6 | SheetContent,
7 | SheetDescription,
8 | SheetHeader,
9 | SheetTitle,
10 | } from '@/components/ui/sheet';
11 | import { useNavigate, useParams, useLocation, Outlet } from '@remix-run/react';
12 | import { ICrudService, Identifiable } from '@/@types';
13 | import { DataTable } from './data-table';
14 | import { ColumnDef } from '@tanstack/react-table';
15 |
16 | interface CrudListProps {
17 | columns: ColumnDef[];
18 | data: T[];
19 | baseRoute: string;
20 | title: string;
21 | }
22 |
23 | export function CrudList<
24 | TL extends Identifiable,
25 | TCU extends Identifiable,
26 | TM extends Identifiable,
27 | TS extends ICrudService
28 | >({ columns, data, baseRoute, title }: CrudListProps) {
29 | const navigate = useNavigate();
30 | const location = useLocation();
31 | const { id } = useParams();
32 |
33 | const isSheetOpen =
34 | location.pathname.includes('/create') ||
35 | location.pathname.includes('/edit') ||
36 | location.pathname.includes('/view');
37 |
38 | const handleClose = () => {
39 | navigate(baseRoute);
40 | };
41 |
42 | const getSheetTitle = () => {
43 | if (location.pathname.includes('/create')) {
44 | return 'Create';
45 | } else if (location.pathname.includes('/edit')) {
46 | return 'Edit';
47 | } else if (location.pathname.includes('/view')) {
48 | return 'View';
49 | }
50 |
51 | return '';
52 | };
53 |
54 | return (
55 |
56 |
57 |
58 |
62 |
63 | {`${getSheetTitle()} ${title}`}
64 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/app/components/layouts/MainLayout.tsx:
--------------------------------------------------------------------------------
1 | import Sidebar from "../blocks/sidebar";
2 | import LayoutHeader from "../blocks/layout-header";
3 | import { useEffect, useState } from "react";
4 | import { cn } from "@/lib/utils";
5 |
6 | interface MainLayoutProps {
7 | title?: string;
8 | action?: React.ReactNode;
9 | children: React.ReactNode;
10 | }
11 |
12 | export default function MainLayout({
13 | children,
14 | action,
15 | title = "Dashboard",
16 | }: MainLayoutProps) {
17 | const [minimalSidebar, setMinimalSidebar] = useState(false);
18 | const toggleSidebar = () => {
19 | writeMinimalSidebarState(!minimalSidebar);
20 | setMinimalSidebar(!minimalSidebar);
21 | };
22 | const [windowWidth, setWindowWidth] = useState(0);
23 | const tabletBreakpoint = 1024;
24 | const moblieBreakpoint = 640;
25 | const isTabletMode = windowWidth < tabletBreakpoint;
26 | const isMobileMode = windowWidth < moblieBreakpoint;
27 |
28 | // write the state of the minmal sidebar to local storage
29 | // so that it persists even after a page refresh
30 | const writeMinimalSidebarState = (state: boolean) => {
31 | localStorage.setItem("minimalSidebar", state.toString());
32 | };
33 |
34 | //TODO: Add a toggle tablet menu callback
35 |
36 | useEffect(() => {
37 | setMinimalSidebar(
38 | localStorage.getItem("minimalSidebar") === "true" ? true : false
39 | );
40 | setWindowWidth(window.innerWidth);
41 | window.addEventListener("resize", () => {
42 | setWindowWidth(window.innerWidth);
43 | });
44 | }, []);
45 |
46 | return (
47 |
57 | {isTabletMode ? null : (
58 |
62 | )}
63 |
64 |
71 | {children}
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/app/routes/_index/components/recent-salse.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | CardDescription,
5 | CardHeader,
6 | CardTitle,
7 | } from "@/components/ui/card";
8 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
9 |
10 | const recentSales = [
11 | {
12 | name: "Olivia Martin",
13 | email: "olivia.martin@email.com",
14 | amount: "+$1,999.00",
15 | avatar: {
16 | src: "/avatars/01.png",
17 | fallback: "OM",
18 | },
19 | },
20 | {
21 | name: "Jackson Lee",
22 | email: "jackson.lee@email.com",
23 | amount: "+$39.00",
24 | avatar: {
25 | src: "/avatars/02.png",
26 | fallback: "JL",
27 | },
28 | },
29 | {
30 | name: "Isabella Nguyen",
31 | email: "isabella.nguyen@email.com",
32 | amount: "+$299.00",
33 | avatar: {
34 | src: "/avatars/03.png",
35 | fallback: "IN",
36 | },
37 | },
38 | {
39 | name: "William Kim",
40 | email: "will@email.com",
41 | amount: "+$99.00",
42 | avatar: {
43 | src: "/avatars/04.png",
44 | fallback: "WK",
45 | },
46 | },
47 | {
48 | name: "Sofia Davis",
49 | email: "sofia.davis@email.com",
50 | amount: "+$39.00",
51 | avatar: {
52 | src: "/avatars/05.png",
53 | fallback: "SD",
54 | },
55 | },
56 | {
57 | name: "Liam Brown",
58 | email: "liam.brown@email.com",
59 | amount: "+$199.00",
60 | avatar: {
61 | src: "/avatars/06.png",
62 | fallback: "LB",
63 | },
64 | },
65 | {
66 | name: "Emily Clark",
67 | email: "emily.clark@email.com",
68 | amount: "+$59.00",
69 | avatar: {
70 | src: "/avatars/07.png",
71 | fallback: "EC",
72 | },
73 | },
74 | ];
75 |
76 | export const RecentSales = () => {
77 | return (
78 |
79 |
80 | Recent Sales
81 |
82 |
83 | {recentSales.map((sale) => (
84 |
85 |
86 |
87 | {sale.avatar.fallback}
88 |
89 |
90 |
{sale.name}
91 |
{sale.email}
92 |
93 |
{sale.amount}
94 |
95 | ))}
96 |
97 |
98 | );
99 | };
100 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "private": true,
4 | "sideEffects": false,
5 | "type": "module",
6 | "scripts": {
7 | "build": "remix vite:build",
8 | "dev": "dotenv -- node ./server.js",
9 | "fix": "biome check . --apply",
10 | "lint": "biome check .",
11 | "start": "cross-env NODE_ENV=production node ./server.js",
12 | "typecheck": "tsc"
13 | },
14 | "dependencies": {
15 | "@faker-js/faker": "^8.4.1",
16 | "@hookform/resolvers": "^3.6.0",
17 | "@radix-ui/react-avatar": "^1.0.4",
18 | "@radix-ui/react-checkbox": "^1.0.4",
19 | "@radix-ui/react-collapsible": "^1.0.3",
20 | "@radix-ui/react-dialog": "^1.0.5",
21 | "@radix-ui/react-dropdown-menu": "2.0.6",
22 | "@radix-ui/react-icons": "1.3.0",
23 | "@radix-ui/react-label": "^2.0.2",
24 | "@radix-ui/react-popover": "^1.0.7",
25 | "@radix-ui/react-select": "^2.0.0",
26 | "@radix-ui/react-separator": "^1.0.3",
27 | "@radix-ui/react-slot": "1.0.2",
28 | "@radix-ui/react-toggle": "^1.0.3",
29 | "@radix-ui/react-toggle-group": "^1.0.4",
30 | "@radix-ui/react-tooltip": "^1.0.7",
31 | "@remix-run/express": "^2.9.2",
32 | "@remix-run/node": "^2.9.2",
33 | "@remix-run/react": "^2.9.2",
34 | "@remix-run/serve": "^2.9.2",
35 | "@t3-oss/env-nextjs": "^0.10.1",
36 | "@tanstack/react-table": "^8.17.3",
37 | "axios": "^1.7.2",
38 | "class-variance-authority": "0.7.0",
39 | "clsx": "2.1.0",
40 | "cmdk": "^1.0.0",
41 | "compression": "1.7.4",
42 | "cross-env": "7.0.3",
43 | "date-fns": "^3.6.0",
44 | "drizzle-orm": "^0.31.2",
45 | "express": "4.18.2",
46 | "isbot": "5.1.0",
47 | "json-as-xlsx": "^2.5.6",
48 | "lucide-react": "^0.381.0",
49 | "morgan": "1.10.0",
50 | "nanoid": "^5.0.7",
51 | "postgres": "^3.4.4",
52 | "prop-types": "^15.8.1",
53 | "react": "18.2.0",
54 | "react-day-picker": "^8.10.1",
55 | "react-dom": "18.2.0",
56 | "react-gauge-chart": "^0.5.1",
57 | "react-gauge-component": "^1.2.21",
58 | "react-hook-form": "^7.51.5",
59 | "remix-utils": "7.5.0",
60 | "sonner": "^1.5.0",
61 | "tailwind-merge": "2.2.1",
62 | "tailwindcss-animate": "1.0.7",
63 | "zod": "^3.23.8"
64 | },
65 | "devDependencies": {
66 | "@biomejs/biome": "1.5.3",
67 | "@remix-run/dev": "^2.9.2",
68 | "@tailwindcss/typography": "0.5.10",
69 | "@types/node": "20.11.19",
70 | "@types/react": "18.2.56",
71 | "@types/react-dom": "18.2.19",
72 | "@types/react-gauge-chart": "^0.4.3",
73 | "autoprefixer": "10.4.17",
74 | "dotenv-cli": "7.3.0",
75 | "drizzle-kit": "^0.22.7",
76 | "pg": "^8.12.0",
77 | "postcss": "8.4.35",
78 | "tailwindcss": "3.4.1",
79 | "tsx": "^4.15.7",
80 | "typescript": "5.3.3",
81 | "vite": "5.1.3",
82 | "vite-env-only": "2.2.0",
83 | "vite-tsconfig-paths": "4.3.1"
84 | },
85 | "engines": {
86 | "node": ">=18.0.0"
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /*
6 | Theme variables
7 | */
8 | @layer base {
9 | :root {
10 | --background: 0 0% 98%;
11 | --foreground: 222.2 84% 4.9%;
12 | --background-dark: 150 50% 1%;
13 | --foreground-dark: 210 40% 98%;
14 |
15 | --card: 0 0% 100%;
16 | --card-foreground: 222.2 84% 4.9%;
17 | --card-dark: 222.2 100% 2%;
18 | --card-foreground-dark: 210 40% 98%;
19 |
20 | --popover: 0 0% 100%;
21 | --popover-foreground: 222.2 84% 4.9%;
22 | --popover-dark: 222.2 84% 4.9%;
23 | --popover-foreground-dark: 210 40% 98%;
24 |
25 | --primary: 222.2 47.4% 11.2%;
26 | --primary-foreground: 210 40% 98%;
27 | --primary-dark: 210 40% 98%;
28 | --primary-foreground-dark: 222.2 47.4% 11.2%;
29 |
30 | --secondary: 210 40% 96.1%;
31 | --secondary-foreground: 222.2 47.4% 11.2%;
32 | --secondary-dark: 217.2 32.6% 17.5%;
33 | --secondary-foreground-dark: 210 40% 98%;
34 |
35 | --muted: 210 40% 96.1%;
36 | --muted-foreground: 215.4 16.3% 46.9%;
37 | --muted-dark: 217.2 32.6% 17.5%;
38 | --muted-foreground-dark: 215 20.2% 65.1%;
39 |
40 | --accent: 210 40% 96.1%;
41 | --accent-foreground: 222.2 47.4% 11.2%;
42 | --accent-dark: 217.2 32.6% 17.5%;
43 | --accent-foreground-dark: 210 40% 98%;
44 |
45 | --destructive: 0 84.2% 60.2%;
46 | --destructive-foreground: 210 40% 98%;
47 | --destructive-dark: 0 62.8% 30.6%;
48 | --destructive-foreground-dark: 210 40% 98%;
49 |
50 | --border: 214.3 31.8% 91.4%;
51 | --input: 214.3 31.8% 91.4%;
52 | --ring: 222.2 84% 4.9%;
53 | --border-dark: 217.2 32.6% 17.5%;
54 | --input-dark: 217.2 32.6% 17.5%;
55 | --ring-dark: 212.7 26.8% 83.9%;
56 |
57 | --radius: 0.5rem;
58 | }
59 | }
60 |
61 | /*
62 | Theme switching based on this tweet from Devon Govett
63 | https://twitter.com/devongovett/status/1757131288144663027
64 | */
65 | @layer base {
66 | :root {
67 | --theme-light: initial;
68 | --theme-dark: ;
69 | color-scheme: light dark;
70 | }
71 |
72 | @media (prefers-color-scheme: dark) {
73 | :root {
74 | --theme-light: ;
75 | --theme-dark: initial;
76 | }
77 | }
78 |
79 | [data-theme='light'] {
80 | --theme-light: initial;
81 | --theme-dark: ;
82 | color-scheme: light;
83 | }
84 |
85 | [data-theme='dark'] {
86 | --theme-light: ;
87 | --theme-dark: initial;
88 | color-scheme: dark;
89 | }
90 | }
91 |
92 | @layer base {
93 | * {
94 | @apply border-border;
95 | }
96 | body {
97 | @apply bg-background text-foreground;
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/app/routes/contributors/route.tsx:
--------------------------------------------------------------------------------
1 | import MainLayout from '@/components/layouts/MainLayout';
2 |
3 | import { Button } from '@/components/ui/button';
4 |
5 | import { Card } from '@/components/ui/card';
6 | import { Plus } from 'lucide-react';
7 |
8 | import { columns } from './components/columns';
9 | import { CrudList } from '@/components/blocks/crud-list';
10 | import { TContributor, TPartialContributor } from '@/@types/contributors';
11 | import { ContributorService } from '@/services/contributors/contributor-service';
12 | import { Await, useLoaderData, useNavigate } from '@remix-run/react';
13 | import { ContributorSummaryCard } from './components/contributor-summary-card';
14 | import { defer } from '@remix-run/node';
15 | import { Suspense } from 'react';
16 |
17 | export async function loader() {
18 | const contributorService = new ContributorService();
19 | const contributorsPromise = contributorService.getAll();
20 | return defer({ contributorsPromise });
21 | }
22 |
23 | // @Authorized(['admin'])
24 | export default function Contributors() {
25 | const navigate = useNavigate();
26 | const { contributorsPromise } = useLoaderData();
27 |
28 | return (
29 | navigate('/contributors/create')}
35 | >
36 | Add Contributor
37 |
38 | }
39 | >
40 |
41 |
42 |
43 |
44 | {/* TODO: Add a proper skeleton */}
45 |
Loading }>
46 |
47 | {(contributors) => (
48 |
49 |
55 | columns={columns}
56 | data={
57 | contributors as unknown as TPartialContributor[]
58 | }
59 | baseRoute={'/contributors'}
60 | title="Contributors"
61 | >
62 |
63 | )}
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/app/components/theme-switcher.tsx:
--------------------------------------------------------------------------------
1 | export type Theme = "light" | "dark" | "system";
2 |
3 | /**
4 | * This component is used to set the theme based on the value at hydration time.
5 | * If no value is found, it will default to the user's system preference and
6 | * coordinates with the ThemeSwitcherScript to prevent a flash of unstyled content
7 | * and a React hydration mismatch.
8 | */
9 | export function ThemeSwitcherSafeHTML({
10 | children,
11 | lang,
12 | ...props
13 | }: React.HTMLProps & { lang: string }) {
14 | const dataTheme =
15 | typeof document === "undefined"
16 | ? undefined
17 | : document.documentElement.getAttribute("data-theme") || undefined;
18 |
19 | return (
20 |
21 | {children}
22 |
23 | );
24 | }
25 |
26 | /**
27 | * This script will run on the client to set the theme based on the value in
28 | * localStorage. If no value is found, it will default to the user's system
29 | * preference.
30 | *
31 | * IMPORTANT: This script should be placed at the end of the tag to
32 | * prevent a flash of unstyled content.
33 | */
34 | export function ThemeSwitcherScript() {
35 | return (
36 |