Compare commits

...

63 Commits

Author SHA1 Message Date
e2e6e4a70a Update path in script 2025-04-03 10:24:23 -05:00
bc9417d36b Clean up Authentik login and login page in general. 2025-01-16 10:55:47 -06:00
be6f80a997 Add back necessary environment variable 2025-01-16 08:58:59 -06:00
902eee260b May have fixed authentik idk 2025-01-16 06:37:22 -06:00
5295d06686 test 2025-01-16 06:18:46 -06:00
ed4d51400c make everything pretty & try to fix authentik 2025-01-15 20:07:04 -06:00
7d637b7401 Fix hopefully? 2025-01-15 16:59:01 -06:00
76c1c40e74 Add authentik 2025-01-15 16:41:21 -06:00
fe3dabe3b7 Set up for docker 2025-01-13 16:36:01 -06:00
0d5197dd94 add dev command 2024-10-05 18:02:42 -05:00
787fcb0031 Fix long statuses. Not perfect but good for now 2024-09-24 11:14:32 -05:00
aab4efbb00 Fix long statuses. Not perfect but good for now 2024-09-24 11:04:34 -05:00
744328c156 Update package.json 2024-07-31 16:00:51 -05:00
f3bb15aaeb Fix date in history for dev environment 2024-07-30 21:02:10 -05:00
d8b1aa242a Make checkbox smaller on mobile 2024-07-30 10:36:51 -05:00
eab772ad99 Fix word wrap with no white spaces for status 2024-07-30 10:25:07 -05:00
0fbd0f7182 Fix word wrap with no white spaces for status 2024-07-30 10:24:08 -05:00
6b80930a86 Make the pagination buttons better by conditionally rendering based on amount of pages 2024-07-30 10:17:43 -05:00
44497ebe7b History Table is now functioning completely 2024-07-30 10:02:27 -05:00
b14383f8fd General History Drawer now works 2024-07-29 14:55:21 -05:00
c95ee5957c General History Drawer now works 2024-07-29 14:54:29 -05:00
8a00507431 Fixes after changing api names 2024-07-25 22:16:16 -05:00
a849b065a1 Update API names now that I can update iOS App 2024-07-25 21:49:38 -05:00
f6af2ff738 Merge branch 'master' of https://git.gibbyb.com/gib/Tech_Tracker_Web 2024-07-25 20:10:20 -05:00
e9a24f0d75 Add env.example 2024-07-25 20:09:35 -05:00
853657fa94 Update README.md 2024-07-25 20:09:05 -05:00
9de6596b04 Update README.md 2024-07-25 19:33:49 -05:00
acf257bc8f Update pnpm on prod 2024-07-23 23:50:56 -05:00
0a29b9f3c8 UI changes 2024-07-23 23:46:27 -05:00
03551be949 Fix the thing 2024-07-23 16:14:23 -05:00
99713d1740 adjust tv mode 2024-07-23 15:55:58 -05:00
c3e0afefa5 New apple touch icon 2024-07-23 15:28:11 -05:00
c089594c7f Formatting 2024-07-23 15:24:53 -05:00
b4ff2da7f5 Add History Drawer & Clean up APIs. Will clean up more when I am home and have updated the iOS app. 2024-07-23 15:18:27 -05:00
ef0a79de21 add bowens svgs 2024-07-23 14:13:25 -05:00
b8d807eb31 Fix title 2024-07-23 13:43:38 -05:00
830ca4c9c4 Fix table header for TV mode 2024-07-23 13:29:02 -05:00
2835681e2a Replace toggle with icon button so it is more clear what the toggle does without adding a label 2024-07-23 13:21:19 -05:00
4dc580bfa2 clean up 2024-07-23 12:27:27 -05:00
abd608ca31 Remove checkbox in tv mode 2024-07-23 12:09:38 -05:00
5a809e5903 Add TV Mode 2024-07-23 11:33:29 -05:00
eb44093f09 idk 2024-07-22 16:46:16 -05:00
7d8d1d9be3 Fix all build errors before updating prod 2024-07-22 15:59:31 -05:00
6456061571 Fix import paths 2024-07-22 15:26:56 -05:00
9c680d459b Clean up code. Install shadcnui elements for later 2024-07-22 15:17:39 -05:00
1df9bff71c Adjust Table Height to fit TV nicely 2024-07-22 09:01:16 -05:00
a451361c0d Ahh just one little thing buggin me! 2024-07-21 21:22:42 -05:00
88f36531b1 Clean up all code 2024-07-21 21:12:34 -05:00
017c07a1cf All good now 2024-07-21 19:48:47 -05:00
0f744f861b kill me 2024-07-21 19:45:17 -05:00
52320227d2 idk server crap 2024-07-21 19:23:39 -05:00
68644842fc I really do not like eslint rn 2024-07-21 19:23:01 -05:00
17898fb3a4 Unfix the fix that broke stuff 2024-07-21 19:18:43 -05:00
c62bb08e4f Fix prod build 2024-07-21 19:16:22 -05:00
10dc66b7fb Added ability to update your own status if you have not selected anyone 2024-07-21 19:14:36 -05:00
1e8c204ed2 Time acts different in dev env vs prod 2024-07-21 18:45:35 -05:00
87c7169b3c I have no idea how but the date bug came back. Should be fixed 2024-07-21 18:39:15 -05:00
1259d19fde Front end stuff for mobile & page when not signed in. 2024-07-21 17:22:04 -05:00
18a397b65a chmod script 2024-07-20 23:37:01 -05:00
0337ff1ad4 Plsgit add -A 2024-07-20 23:35:11 -05:00
330b2ce904 Fix some date stuff I missed 2024-07-20 23:30:20 -05:00
f572748de5 Finally a production ready application 2024-07-20 22:41:47 -05:00
c70144bb22 Finally a production ready application 2024-07-20 22:40:16 -05:00
103 changed files with 6296 additions and 1931 deletions

5
.eslintrc.cjs Normal file → Executable file
View File

@ -29,6 +29,9 @@ const config = {
"argsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-empty-interface": [
"warn",
],
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-misused-promises": [
"error",
@ -58,4 +61,4 @@ const config = {
]
}
}
module.exports = config;
module.exports = config;

0
.gitignore vendored Normal file → Executable file
View File

5
.prettierrc Normal file
View File

@ -0,0 +1,5 @@
{
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "all"
}

4
.prod/Dockerfile Executable file
View File

@ -0,0 +1,4 @@
FROM node:latest
WORKDIR /home/node/app
RUN npm install -g pnpm
CMD ["pnpm", "go"]

4
.prod/update.sh Executable file
View File

@ -0,0 +1,4 @@
cd ~/Documents/Web/Tech_Tracker_Web || exit
git pull
pnpm update
sudo docker restart techtracker

56
README.md Normal file → Executable file
View File

@ -1,7 +1,55 @@
<img src="https://git.gibbyb.com/gib/Tech_Tracker_Web/raw/branch/master/public/images/tech_tracker_logo.png" alt="Tech Tracker Logo" width="50"/>
<h1 align="center">
<br>
<a href="https://techtracker.gibbyb.com"><img src="https://git.gibbyb.com/gib/Tech_Tracker_Web/raw/branch/master/public/images/tech_tracker_logo.png" alt="Tech Tracker Logo" width="100"></a>
<br>
<b>Tech Tracker</b>
<br>
</h1>
# Tech Tracker Website
### [Find Here](https://techtracker.gibbyb.com/)
# [Find Here](https://techtracker.gibbyb.com/)
- Application used by COG employees to update their status & location throughout the day.
<details>
<summary>
<h3>How to run:</h3>
</summary>
I'd recommend installing pnpm. Clone the repo, then rename env.example to .env & fill it out.
```bash
mv ./env.example ./.env
```
Run
```bash
pnpm install
```
to install all dependencies.
Feel free to use whichever providers you would like with Auth.js. Outside of changing the logo on the sign in button, you should be able to swap easily. Just ensure you read over the documentation.
Once you have all your environment variables, you can run
```bash
pnpm db:push
```
to automatically push the database schema to your database. You can then run
```bash
pnpm db:studio
```
to get a nice web ui where you can manipulate data in your database. Once your database is set up & you have added your users, you can run
```bash
pnpm dev
```
to start your development environment on port 3000.
For prod, look in the .prod folder. You will find a Dockerfile which when started will always pull any updates before starting with a custom command `pnpm go` which is aliased to `git pull && next build && next start`
</details>

0
components.json Normal file → Executable file
View File

View File

@ -0,0 +1,53 @@
# syntax=docker.io/docker/dockerfile:1
FROM node:18-alpine AS base
# 1. Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i; \
else echo "Lockfile not found." && exit 1; \
fi
# 2. Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# This will do the trick, use the corresponding env file for each environment.
COPY .env .env.production
RUN npm run build
# 3. Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD HOSTNAME="0.0.0.0" node server.js

View File

@ -0,0 +1,17 @@
services:
techtracker:
build:
context: ../../
dockerfile: docker/development/Dockerfile
image: with-docker-multi-env-development
container_name: techtracker
networks:
- node_apps
ports:
- "3004:3000"
tty: true
restart: unless-stopped
networks:
node_apps:
external: true

View File

@ -0,0 +1,54 @@
# syntax=docker.io/docker/dockerfile:1
FROM node:18-alpine AS base
# 1. Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i; \
else echo "Lockfile not found." && exit 1; \
fi
# 2. Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# This will do the trick, use the corresponding env file for each environment.
COPY .env .env.production
RUN npm run build
# 3. Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD HOSTNAME="0.0.0.0" node server.js

View File

@ -0,0 +1,16 @@
services:
techtracker:
build:
context: ../../
dockerfile: docker/production/Dockerfile
image: with-docker-multi-env-development
container_name: techtracker
networks:
- node_apps
ports:
- "3004:3000"
tty: true
restart: unless-stopped
networks:
node_apps:
external: true

10
drizzle.config.ts Normal file → Executable file
View File

@ -1,12 +1,12 @@
import { type Config } from "drizzle-kit";
import { type Config } from 'drizzle-kit';
import { env } from "~/env";
import { env } from '~/env';
export default {
schema: "./src/server/db/schema.ts",
dialect: "mysql",
schema: './src/server/db/schema.ts',
dialect: 'mysql',
dbCredentials: {
url: env.DATABASE_URL,
},
tablesFilter: ["tech_tracker_web_*"],
tablesFilter: ['tech_tracker_web_*'],
} satisfies Config;

16
env.example Normal file
View File

@ -0,0 +1,16 @@
# When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly.
# Drizzle
DATABASE_URL=""
# API Key
API_KEY=""
# Auth.js
#NEXTAUTH_SECRET=""
AUTH_SECRET=""
# Entra - https://authjs.dev/getting-started/providers/microsoft-entra-id
AUTH_MICROSOFT_ENTRA_ID_ID=""
AUTH_MICROSOFT_ENTRA_ID_SECRET=""
AUTH_MICROSOFT_ENTRA_ID_TENANT_ID=""

View File

@ -2,36 +2,48 @@
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
await import("./src/env.js");
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
import './src/env.js';
/** @type {import("next").NextConfig} */
const config = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: cspHeader.replace(/\n/g, ''),
},
],
},
];
},
output: 'standalone',
};
export default config;
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
//await import("./src/env.js");
//const cspHeader = `
//default-src 'self';
//script-src 'self' 'unsafe-eval' 'unsafe-inline';
//style-src 'self' 'unsafe-inline';
//img-src 'self' blob: data:;
//font-src 'self';
//object-src 'none';
//base-uri 'self';
//form-action 'self';
//frame-ancestors 'none';
//upgrade-insecure-requests;
//`
//[>* @type {import("next").NextConfig} <]
//const config = {
//async headers() {
//return [
//{
//source: "/(.*)",
//headers: [
//{
//key: "Content-Security-Policy",
//value: cspHeader.replace(/\n/g, ''),
//},
//],
//},
//];
//},
//};
//export default config;

65
package.json Normal file → Executable file
View File

@ -12,43 +12,66 @@
"dev": "next dev",
"lint": "next lint",
"start": "next start",
"go": "next build && next start"
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@t3-oss/env-nextjs": "^0.10.1",
"class-variance-authority": "^0.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"date-fns": "^3.6.0",
"drizzle-orm": "^0.30.10",
"geist": "^1.3.1",
"lucide-react": "^0.411.0",
"mysql2": "^3.10.3",
"next": "^14.2.5",
"mysql2": "^3.12.0",
"next": "^14.2.23",
"next-auth": "5.0.0-beta.19",
"pm2": "^5.4.2",
"next-themes": "^0.3.0",
"pm2": "^5.4.3",
"react": "^18.3.1",
"react-day-picker": "^9.5.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"server-only": "^0.0.1",
"sharp": "^0.33.4",
"tailwind-merge": "^2.4.0",
"sharp": "^0.33.5",
"sonner": "^1.7.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
"vaul": "^0.9.9",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/eslint": "^8.56.10",
"@types/node": "^20.14.11",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"@types/eslint": "^8.56.12",
"@types/node": "^20.17.14",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"drizzle-kit": "^0.21.4",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.5",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.23",
"eslint-plugin-drizzle": "^0.2.3",
"postcss": "^8.4.39",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3"
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.10",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
},
"ct3aMetadata": {
"initVersion": "7.36.1"

3737
pnpm-lock.yaml generated Normal file → Executable file

File diff suppressed because it is too large Load Diff

0
postcss.config.cjs Normal file → Executable file
View File

2
prettier.config.js Normal file → Executable file
View File

@ -1,6 +1,6 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
const config = {
plugins: ["prettier-plugin-tailwindcss"],
plugins: ['prettier-plugin-tailwindcss'],
};
export default config;

0
public/favicon.ico Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 994.71 151.65"><defs><style>.cls-1{fill:#fd4b2d;}</style></defs><path class="cls-1" d="M284.72,50.4H305.5v82.84H284.72v-8.76a40.79,40.79,0,0,1-12.21,8.34,34.14,34.14,0,0,1-13.27,2.55q-16.05,0-27.76-12.45T219.77,92q0-19.18,11.33-31.45t27.53-12.26a34.94,34.94,0,0,1,14,2.82,38.32,38.32,0,0,1,12.1,8.45ZM262.87,67.45a21,21,0,0,0-16,6.82q-6.37,6.81-6.38,17.47T247,109.4a21,21,0,0,0,16,6.93,21.42,21.42,0,0,0,16.24-6.81q6.45-6.81,6.45-17.86,0-10.8-6.45-17.51A21.71,21.71,0,0,0,262.87,67.45Z"/><path class="cls-1" d="M335.8,50.4h21V90.29q0,11.65,1.6,16.18a14.16,14.16,0,0,0,5.16,7,14.76,14.76,0,0,0,8.74,2.51,15.25,15.25,0,0,0,8.81-2.48,14.49,14.49,0,0,0,5.38-7.27q1.31-3.57,1.3-15.3V50.4h20.79V85.5q0,21.69-3.43,29.69a32.32,32.32,0,0,1-12.33,15q-8.16,5.22-20.71,5.22-13.64,0-22.05-6.09a32.2,32.2,0,0,1-11.84-17q-2.43-7.55-2.43-27.41Z"/><path class="cls-1" d="M441.32,19.86H462.1V50.4h12.34V68.29H462.1v65H441.32V68.29H430.66V50.4h10.66Z"/><path class="cls-1" d="M495,18.42h20.63V58.77a47.41,47.41,0,0,1,12.26-7.88,31.62,31.62,0,0,1,12.49-2.63,28.13,28.13,0,0,1,20.78,8.53q7.23,7.4,7.24,21.7v54.75H547.9V96.92q0-14.4-1.37-19.49a13.6,13.6,0,0,0-4.68-7.62,13.19,13.19,0,0,0-8.18-2.51,15.43,15.43,0,0,0-10.85,4.19,22.14,22.14,0,0,0-6.28,11.42q-.91,3.72-.92,17v33.28H495Z"/><path class="cls-1" d="M680.84,97.83H614.06a22.25,22.25,0,0,0,7.73,14q6.29,5.22,16,5.21a27.7,27.7,0,0,0,20-8.14l17.51,8.22a41.31,41.31,0,0,1-15.68,13.74q-9.13,4.46-21.7,4.46-19.5,0-31.75-12.3T594,92.27q0-19,12.22-31.48t30.65-12.53q19.56,0,31.82,12.53t12.26,33.08ZM660.05,81.46a20.87,20.87,0,0,0-8.12-11.27,23.61,23.61,0,0,0-14.08-4.34,24.88,24.88,0,0,0-15.25,4.88q-4.11,3-7.62,10.73Z"/><path class="cls-1" d="M707,50.4H727.8v8.49a50.15,50.15,0,0,1,12.81-8.3,31.08,31.08,0,0,1,11.75-2.33,28.44,28.44,0,0,1,20.91,8.61q7.22,7.31,7.22,21.62v54.75H759.93V97q0-14.83-1.33-19.7A13.48,13.48,0,0,0,754,69.85a13,13,0,0,0-8.16-2.55A15.32,15.32,0,0,0,735,71.52a22.6,22.6,0,0,0-6.27,11.67q-.9,3.89-.91,16.81v33.24H707Z"/><path class="cls-1" d="M812.46,19.86h20.79V50.4h12.33V68.29H833.25v65H812.46V68.29H801.8V50.4h10.66Z"/><path class="cls-1" d="M874.16,16.29a12.74,12.74,0,0,1,9.38,3.95,13.18,13.18,0,0,1,3.91,9.6,13,13,0,0,1-3.87,9.48,12.6,12.6,0,0,1-9.27,3.92,12.73,12.73,0,0,1-9.45-4A13.39,13.39,0,0,1,861,29.53a12.78,12.78,0,0,1,3.87-9.36A12.71,12.71,0,0,1,874.16,16.29Z"/><rect class="cls-1" x="863.77" y="50.4" width="20.79" height="82.84"/><path class="cls-1" d="M913,18.42h20.78V84.55L964.34,50.4h26.11L954.76,90.1l40,43.14h-25.8L933.73,95.06v38.18H913Z"/><rect class="cls-1" x="107.1" y="34.93" width="6.37" height="18.2"/><rect class="cls-1" x="123.67" y="34.16" width="6.37" height="14.23"/><path class="cls-1" d="M30.83,55A23.23,23.23,0,0,0,10.41,67.13h10.8C26,63,32.94,61.8,38,67.13H49.39C44.93,61.09,38.24,55,30.83,55Z"/><path class="cls-1" d="M46.25,78.11c-14.89,31.15-41,4.6-25-11H10.41c-8.47,14.76,3.24,34.68,20.42,34.23,13.28,0,24.24-19.72,24.24-23.21,0-1.54-2.14-6.25-5.68-11H38A40.52,40.52,0,0,1,46.25,78.11Zm.4-.91Z"/><path class="cls-1" d="M189.62,34.71V117A28.62,28.62,0,0,1,161,145.54H148.89v-28H90.94v28H78.81A28.62,28.62,0,0,1,50.22,117V91.08h91.87V41.62H97.74V69.41H50.22V34.71a27.43,27.43,0,0,1,.19-3.29,27.09,27.09,0,0,1,.71-3.84c.1-.41.22-.82.34-1.21a2.13,2.13,0,0,1,.09-.3c.07-.21.13-.4.2-.59s.14-.4.21-.59.16-.44.25-.65.18-.43.26-.64a29.35,29.35,0,0,1,2.6-4.82l0-.05c.26-.37.53-.75.81-1.12s.47-.61.7-.91.57-.67.86-1,.56-.63.86-.93l0,0a4.53,4.53,0,0,1,.49-.49,29.23,29.23,0,0,1,3.4-2.84c.32-.24.66-.46,1-.68s.77-.49,1.17-.72a23.78,23.78,0,0,1,2.29-1.21l.75-.34a27.84,27.84,0,0,1,3.35-1.21c.44-.13.88-.24,1.33-.35a6.19,6.19,0,0,1,.65-.15,28.86,28.86,0,0,1,3.87-.57l.56,0h.28c.43,0,.87,0,1.31,0H161c.43,0,.87,0,1.3,0h.28l.56,0a29.25,29.25,0,0,1,3.88.57c.22,0,.43.09.65.15.45.11.88.22,1.32.35a27.23,27.23,0,0,1,3.35,1.21l.75.34a25.19,25.19,0,0,1,2.3,1.21c.39.23.78.47,1.16.72s.69.44,1,.68a29.23,29.23,0,0,1,3.91,3.36q.45.45.87.93c.29.32.57.66.85,1l.71.91c.28.37.54.75.8,1.12l0,.05a28.61,28.61,0,0,1,2.6,4.82l.27.64.24.65c.08.19.15.39.22.59l.19.59c0,.09.06.19.1.3.11.39.23.8.34,1.21a28.56,28.56,0,0,1,.7,3.84A27.42,27.42,0,0,1,189.62,34.71Z"/><path class="cls-1" d="M184.76,18.78H55.07A28.59,28.59,0,0,1,78.8,6.12H161A28.59,28.59,0,0,1,184.76,18.78Z"/><path class="cls-1" d="M189.43,31.43H50.4a28.29,28.29,0,0,1,4.67-12.65H184.76A28.17,28.17,0,0,1,189.43,31.43Z"/><path class="cls-1" d="M189.63,34.71v9.37H142.09V41.62H97.74v2.46H50.21V34.71a27.43,27.43,0,0,1,.19-3.29h139A27.42,27.42,0,0,1,189.63,34.71Z"/><rect class="cls-1" x="50.21" y="44.08" width="47.54" height="12.66"/><rect class="cls-1" x="142.09" y="44.08" width="47.54" height="12.66"/><rect class="cls-1" x="50.21" y="56.74" width="47.54" height="12.65"/><rect class="cls-1" x="142.09" y="56.74" width="47.54" height="12.65"/></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

0
public/images/default_user_pfp.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 12.7 12.7"
version="1.1"
id="svg513"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
sodipodi:docname="ExitFullscreen.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview515"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="8.4359982"
inkscape:cx="69.108597"
inkscape:cy="37.458519"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="3832"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs510" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:#b3b3b3;stroke-width:0.193906"
d="M 0,5.8620045 H 5.8615381 L 5.8621526,0 H 4.8849607 L 4.8846152,4.8851478 H 0 Z"
id="path190"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193906"
d="M 6.8384615,8.6556325e-4 V 5.8620045 H 12.7 V 4.8851478 H 7.815384 V 8.6556325e-4 Z"
id="path396"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193906"
d="M 12.7,6.8388612 H 6.8384615 V 12.7 H 7.815384 V 7.8157173 H 12.7 Z"
id="path396-5"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193906"
d="M 5.8615381,12.7 V 6.8388612 H 0 V 7.8157173 H 4.8846152 V 12.7 Z"
id="path396-1"
sodipodi:nodetypes="ccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

63
public/images/fullscreen.svg Executable file
View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 12.7 12.7"
version="1.1"
id="svg513"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
sodipodi:docname="Fullscreen.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview515"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="8.4359982"
inkscape:cx="69.108597"
inkscape:cy="37.458519"
inkscape:window-width="1920"
inkscape:window-height="1017"
inkscape:window-x="3832"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs510" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:#b3b3b3;stroke-width:0.193912"
d="M 5.8615386,0 H 0 V 5.8615386 H 0.97692256 V 0.97692327 H 5.8615386 Z"
id="path396-6"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193912"
d="M 0,6.8384619 V 12.7 H 5.8615386 V 11.723076 H 0.97692256 V 6.8384619 Z"
id="path396-52"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193912"
d="M 6.8384613,12.7 H 12.7 V 6.8384619 H 11.723078 V 11.723076 H 6.8384613 Z"
id="path396-4"
sodipodi:nodetypes="ccccccc" />
<path
style="fill:#b3b3b3;stroke-width:0.193912"
d="M 12.7,5.8615386 V 0 H 6.8384613 V 0.97692327 H 11.723078 V 5.8615386 Z"
id="path396-0"
sodipodi:nodetypes="ccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

1
public/images/gitea_logo.svg Executable file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640" width="32" height="32"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" style="fill:#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" style="fill:#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" style="fill:#609926"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

0
public/images/microsoft_logo.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 260 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?> <svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" width="779.07" height="141.73" viewBox="0 0 779.07 141.73"><title>Microsoft365_logo_horiz_c-gray_cmyk_horiz_c-gray_cmyk</title><g id="MS-symbol"><g><path d="M608.2,69.69v.19c5,.58,8.89,2.32,11.76,5.23a15,15,0,0,1,4.32,11,18.7,18.7,0,0,1-6.89,15.15q-6.9,5.75-18.74,5.74a41.61,41.61,0,0,1-9.44-1.08,23,23,0,0,1-7.08-2.62V92.11a25,25,0,0,0,7.56,4,27,27,0,0,0,8.71,1.51q6.44,0,10.19-3a9.94,9.94,0,0,0,3.72-8.14,9.39,9.39,0,0,0-4.41-8.39c-2.95-1.93-7.15-2.89-12.63-2.89h-6V65.61h5.71q7.26,0,11.18-2.7A8.92,8.92,0,0,0,610,55a9,9,0,0,0-3-7.32c-2-1.71-4.92-2.57-8.67-2.56a20.68,20.68,0,0,0-7,1.22,24.17,24.17,0,0,0-6.58,3.67V39.59a28.42,28.42,0,0,1,7.45-2.8,39.73,39.73,0,0,1,9.09-1q9,0,14.82,4.66a14.89,14.89,0,0,1,5.78,12.11,16.73,16.73,0,0,1-3.55,11A18.89,18.89,0,0,1,608.2,69.69Z" fill="#737474"></path><path d="M643,70a12.92,12.92,0,0,1,6.1-5.76,20.9,20.9,0,0,1,9.17-2,20,20,0,0,1,14,5.45q5.88,5.46,5.88,15.84,0,10.95-6.74,17.19a23.27,23.27,0,0,1-16.4,6.24q-11.28,0-17.76-8.38T630.8,75.22q0-19.5,8.61-29.48a27.4,27.4,0,0,1,21.64-10,50.12,50.12,0,0,1,7.24.47,18.64,18.64,0,0,1,5.33,1.49V48.13a26.26,26.26,0,0,0-6-2.26,25.55,25.55,0,0,0-6-.74A16.55,16.55,0,0,0,648,51.65q-5.15,6.52-5.24,18.32Zm.19,13.54a14.92,14.92,0,0,0,3.37,9.92,10.7,10.7,0,0,0,8.55,4A10.54,10.54,0,0,0,663.33,94q3.15-3.47,3.14-9.53,0-6.42-3.08-9.72A10.86,10.86,0,0,0,655,71.47a11.47,11.47,0,0,0-8.57,3.33,11.93,11.93,0,0,0-3.23,8.76Z" fill="#737474"></path><path d="M725.94,84.33q0,10.24-6.86,16.49T700.21,107a35.2,35.2,0,0,1-9.46-1.23,32.77,32.77,0,0,1-7-2.66V92.3a25.77,25.77,0,0,0,7.6,4,24.67,24.67,0,0,0,7.52,1.27q6.87,0,11-3.41A11.33,11.33,0,0,0,714,84.91,10.5,10.5,0,0,0,709.79,76q-4.24-3.18-12.22-3.17c-1.57,0-3.62.07-6.17.21s-4.3.28-5.26.41L688.59,37h34.23v9.86H698.19l-1.13,16.77c1.34-.11,2.38-.16,3.09-.17h3Q714,63.41,720,69T725.94,84.33Z" fill="#737474"></path></g><path d="M239.64,37v68.84h-12V51.87h-.19l-21.36,54h-7.92L176.29,51.87h-.14v54h-11V37h17.14L202,88h.29l20.89-51Zm10,5.23a6.33,6.33,0,0,1,2.09-4.82,7.41,7.41,0,0,1,10.06,0,6.58,6.58,0,0,1,2,4.78A6.21,6.21,0,0,1,261.74,47a7.07,7.07,0,0,1-5,1.92,7,7,0,0,1-5-1.93,6.29,6.29,0,0,1-2.08-4.74Zm12.82,14.27v49.37H250.82V56.48ZM297.7,97.37a15.76,15.76,0,0,0,5.67-1.19A24,24,0,0,0,309.13,93v10.81a23.43,23.43,0,0,1-6.31,2.4,34.86,34.86,0,0,1-7.76.82q-10.89,0-17.7-6.9t-6.82-17.59q0-11.89,7-19.56t19.72-7.8a27.28,27.28,0,0,1,6.61.84,22,22,0,0,1,5.3,2V69.17a23.31,23.31,0,0,0-5.5-3A15.8,15.8,0,0,0,297.9,65a14.55,14.55,0,0,0-11.09,4.46q-4.23,4.47-4.22,12.06t4.05,11.66q4,4.17,11,4.15Zm44.51-41.71a14,14,0,0,1,2.5.19,10,10,0,0,1,1.86.48V68.09a10.17,10.17,0,0,0-2.66-1.27,13.33,13.33,0,0,0-4.25-.6,9.05,9.05,0,0,0-7.23,3.6q-3,3.6-2.95,11.09v24.91H317.9V56.45h11.61v7.77h.2A13.55,13.55,0,0,1,334.48,58,13,13,0,0,1,342.21,55.66Zm5,26.21q0-12.24,6.92-19.39t19.21-7.16q11.57,0,18.07,6.89t6.52,18.63q0,12-6.91,19.11t-18.82,7.1q-11.47,0-18.21-6.74T347.2,81.87Zm12.12-.39q0,7.74,3.5,11.81t10,4.08q6.34,0,9.65-4.08t3.32-12.11q0-8-3.44-12t-9.62-4.07q-6.39,0-9.91,4.25t-3.55,12.14Zm55.89-12a5,5,0,0,0,1.59,3.91q1.59,1.41,7,3.58,7,2.79,9.76,6.26a12.91,12.91,0,0,1,2.8,8.38A13.52,13.52,0,0,1,431,102.75Q425.66,107,416.54,107a35.4,35.4,0,0,1-6.8-.75,30.18,30.18,0,0,1-6.3-1.86V93a28.2,28.2,0,0,0,6.82,3.5,19.93,19.93,0,0,0,6.62,1.3,11.83,11.83,0,0,0,5.8-1.1,4,4,0,0,0,1.87-3.73,5.11,5.11,0,0,0-1.94-4.05,29,29,0,0,0-7.37-3.82q-6.45-2.69-9.13-6a13.21,13.21,0,0,1-2.68-8.55,13.47,13.47,0,0,1,5.3-11q5.31-4.3,13.76-4.31a33.14,33.14,0,0,1,5.8.57,25.88,25.88,0,0,1,5.38,1.49V68.33a25.11,25.11,0,0,0-5.38-2.64,17.89,17.89,0,0,0-6.05-1.11,8.82,8.82,0,0,0-5.16,1.31,4.1,4.1,0,0,0-1.9,3.55Zm26.17,12.43q0-12.24,6.91-19.39t19.2-7.16q11.58,0,18.08,6.89t6.52,18.63q0,12-6.91,19.11t-18.83,7.1q-11.48,0-18.21-6.74t-6.79-18.44Zm12.11-.39q0,7.74,3.51,11.81T467,97.37q6.33,0,9.65-4.08t3.31-12.11q0-8-3.43-12t-9.63-4.07q-6.37,0-9.91,4.25t-3.49,12.14ZM530.64,66H513.29v39.84H501.53V66h-8.26v-9.5h8.26V49.6a17.06,17.06,0,0,1,5.06-12.74,17.82,17.82,0,0,1,13-5,29.06,29.06,0,0,1,3.73.22,15,15,0,0,1,2.88.65v10a13.26,13.26,0,0,0-2-.82,10.54,10.54,0,0,0-3.3-.47,7,7,0,0,0-5.59,2.28q-2,2.28-2,6.74v6h17.3V45.38l11.67-3.55V56.48H554V66H542.26V89.07q0,4.56,1.65,6.43t5.21,1.87a7.66,7.66,0,0,0,2.42-.48A11.92,11.92,0,0,0,554,95.73v9.61a14.24,14.24,0,0,1-3.67,1.15,26.12,26.12,0,0,1-5.07.53q-7.35,0-11-3.92t-3.67-11.78Z" fill="#737474"></path><rect x="15.94" y="14.03" width="54.53" height="54.53" fill="#f05125"></rect><rect x="76.14" y="14.03" width="54.53" height="54.53" fill="#7ebb42"></rect><rect x="15.94" y="74.24" width="54.53" height="54.53" fill="#33a0da"></rect><rect x="76.14" y="74.24" width="54.53" height="54.53" fill="#fdb813"></rect></g></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

0
public/images/tech_tracker_logo.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 386 KiB

After

Width:  |  Height:  |  Size: 386 KiB

105
scripts/files_to_clipboard.py Executable file
View File

@ -0,0 +1,105 @@
#!/usr/bin/env python3
import os
import sys
import argparse
from pathlib import Path
import pyperclip
import questionary
# List of directories to exclude
EXCLUDED_DIRS = {'node_modules', '.next', '.venv', '.git', '__pycache__', '.idea', '.vscode', 'ui'}
def collect_files(project_path):
"""
Collects files from the project directory, excluding specified directories and filtering by extensions.
Returns a list of file paths relative to the project directory.
"""
collected_files = []
for root, dirs, files in os.walk(project_path):
# Exclude specified directories
dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
for file in files:
file_path = Path(root) / file
relative_path = file_path.relative_to(project_path)
collected_files.append(relative_path)
return collected_files
def main():
# Parse command-line arguments
parser = argparse.ArgumentParser(description='Generate Markdown from selected files.')
parser.add_argument('path', nargs='?', default='.', help='Path to the project directory')
args = parser.parse_args()
project_path = Path(args.path).resolve()
if not project_path.is_dir():
print(f"Error: '{project_path}' is not a directory.")
sys.exit(1)
# Collect files from the project directory
file_list = collect_files(project_path)
if not file_list:
print("No files found in the project directory with the specified extensions.")
sys.exit(1)
# Sort file_list for better organization
file_list.sort()
# Interactive file selection using questionary
print("\nSelect the files you want to include:")
selected_files = questionary.checkbox(
"Press space to select files, and Enter when you're done:",
choices=[str(f) for f in file_list]
).ask()
if not selected_files:
print("No files selected.")
sys.exit(1)
# Generate markdown
markdown_lines = []
markdown_lines.append('')
for selected_file in selected_files:
file_path = project_path / selected_file
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Determine the language for code block from file extension
language = file_path.suffix.lstrip('.')
markdown_lines.append(f'{selected_file}')
markdown_lines.append(f'```{language}')
markdown_lines.append(content)
markdown_lines.append('```')
markdown_lines.append('')
except Exception as e:
print(f"Error reading file {selected_file}: {e}")
markdown_text = '\n'.join(markdown_lines)
# Write markdown to file
output_file = 'output.md'
with open(output_file, 'w', encoding='utf-8') as f:
f.write(markdown_text)
print(f"\nMarkdown file '{output_file}' has been generated.")
# Copy markdown content to clipboard
pyperclip.copy(markdown_text)
print("Markdown content has been copied to the clipboard.")
if __name__ == "__main__":
# Check if required libraries are installed
try:
import questionary
import pyperclip
except ImportError as e:
missing_module = e.name
print(f"Error: Missing required module '{missing_module}'.")
print(f"Please install it by running: pip install {missing_module}")
sys.exit(1)
main()

View File

@ -0,0 +1,18 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import './src/env.js';
/** @type {import("next").NextConfig} */
const config = {
output: 'standalone',
typescript: {
ignoreBuildErrors: true,
},
eslint: {
ignoreDuringBuilds: true,
},
};
export default config;

View File

@ -0,0 +1,49 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import './src/env.js';
/** @type {import("next").NextConfig} */
const config = {
output: 'standalone',
};
export default config;
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
//await import("./src/env.js");
//const cspHeader = `
//default-src 'self';
//script-src 'self' 'unsafe-eval' 'unsafe-inline';
//style-src 'self' 'unsafe-inline';
//img-src 'self' blob: data:;
//font-src 'self';
//object-src 'none';
//base-uri 'self';
//form-action 'self';
//frame-ancestors 'none';
//upgrade-insecure-requests;
//`
//[>* @type {import("next").NextConfig} <]
//const config = {
//async headers() {
//return [
//{
//source: "/(.*)",
//headers: [
//{
//key: "Content-Security-Policy",
//value: cspHeader.replace(/\n/g, ''),
//},
//],
//},
//];
//},
//};
//export default config;

7
scripts/reload_container.sh Executable file
View File

@ -0,0 +1,7 @@
git pull
mv ./next.config.js ./scripts/next.config.default.js
cp ./scripts/next.config.build.js ./next.config.js
sudo docker compose -f docker/development/compose.yaml down
sudo docker compose -f docker/development/compose.yaml build
sudo docker compose -f docker/development/compose.yaml up -d
cp ./scripts/next.config.default.js ./next.config.js

4
src/app/api/auth/[...nextauth]/route.ts Normal file → Executable file
View File

@ -1,2 +1,2 @@
import { handlers } from "~/auth"
export const { GET, POST } = handlers
import { handlers } from '~/auth';
export const { GET, POST } = handlers;

View File

@ -0,0 +1,27 @@
'use server';
import { NextResponse } from 'next/server';
import { get_history } from '~/server/functions';
import { auth } from '~/auth';
export const GET = async (request: Request) => {
try {
const url = new URL(request.url);
const apiKey = url.searchParams.get('apikey');
const userId = Number(url.searchParams.get('user_id')) || -1;
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('per_page')) || 50;
if (apiKey !== process.env.API_KEY) {
const session = await auth();
if (!session)
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
const historyData = await get_history(userId, page, perPage);
return NextResponse.json(historyData, { status: 200 });
} catch (error) {
console.error('Error fetching history data:', error);
return NextResponse.json(
{ message: 'Internal server error' },
{ status: 500 },
);
}
};

View File

@ -0,0 +1,29 @@
'use server';
import { NextResponse } from 'next/server';
import { getEmployees } from '~/server/functions';
import { auth } from '~/auth';
export const GET = async (request: Request) => {
try {
const session = await auth();
if (!session) {
const url = new URL(request.url);
const apiKey = url.searchParams.get('apikey');
if (apiKey !== process.env.API_KEY)
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
else {
const employees = await getEmployees();
return NextResponse.json(employees, { status: 200 });
}
} else {
const employees = await getEmployees();
return NextResponse.json(employees, { status: 200 });
}
} catch (error) {
console.error('Error fetching employees:', error);
return NextResponse.json(
{ message: 'Internal server error' },
{ status: 500 },
);
}
};

View File

@ -1,23 +0,0 @@
"use server";
import { NextResponse } from 'next/server';
import { legacyGetHistory } from '~/server/functions';
export const GET = async (request: Request) => {
try {
const url = new URL(request.url);
const apiKey = url.searchParams.get('apikey');
const page = Number(url.searchParams.get('page')) || 1;
if (apiKey !== 'zAf4vYVN2pszrK') {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
const perPage = 50; // You can adjust the perPage value as needed
const historyData = await legacyGetHistory(page, perPage);
return NextResponse.json(historyData, { status: 200 });
} catch (error) {
console.error('Error fetching history data:', error);
return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
}
};

View File

@ -1,33 +0,0 @@
"use server";
import { NextResponse } from 'next/server';
import { legacyGetEmployees } from '~/server/functions';
type Technician = {
name: string;
status: string;
updatedAt: Date;
};
export const GET = async (request: Request) => {
try {
const url = new URL(request.url);
const apiKey = url.searchParams.get('apikey');
if (apiKey !== 'zAf4vYVN2pszrK') {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
const employees = await legacyGetEmployees();
const formattedEmployees = employees.map((employee: Technician) => ({
name: employee.name,
status: employee.status,
time: employee.updatedAt
}));
return NextResponse.json(formattedEmployees, { status: 200 });
} catch (error) {
console.error('Error fetching employees:', error);
return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
}
};

View File

@ -0,0 +1,38 @@
// Update Employee Status by IDs
'use server';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { updateEmployeeStatus } from '~/server/functions';
import { auth } from '~/auth';
type UpdateStatusBody = {
employeeIds: string[];
newStatus: string;
};
export const POST = async (req: NextRequest) => {
const session = await auth();
if (!session) {
const url = new URL(req.url);
const apiKey = url.searchParams.get('apikey');
if (apiKey !== process.env.API_KEY)
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
} else {
const { employeeIds, newStatus } = (await req.json()) as UpdateStatusBody;
if (!Array.isArray(employeeIds) || typeof newStatus !== 'string')
return NextResponse.json({ message: 'Invalid input' }, { status: 400 });
try {
await updateEmployeeStatus(employeeIds, newStatus);
return NextResponse.json(
{ message: 'Status updated successfully' },
{ status: 200 },
);
} catch (error) {
console.error('Error updating status:', error);
return NextResponse.json(
{ message: 'Internal server error' },
{ status: 500 },
);
}
}
};

View File

@ -0,0 +1,57 @@
// Update Employee Status by Names
'use server';
import { NextResponse } from 'next/server';
import { updateEmployeeStatusByName } from '~/server/functions';
type Technician = {
name: string;
status: string;
};
// Type guard to check if an object is a Technician
const isTechnician = (technician: unknown): technician is Technician => {
if (typeof technician !== 'object' || technician === null) return false;
return (
'name' in technician &&
typeof (technician as Technician).name === 'string' &&
'status' in technician &&
typeof (technician as Technician).status === 'string'
);
};
export const POST = async (request: Request) => {
try {
const url = new URL(request.url);
const apiKey = url.searchParams.get('apikey');
if (apiKey !== process.env.API_KEY)
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
const body: unknown = await request.json();
// Validate the body and its technicians property
if (
typeof body !== 'object' ||
body === null ||
!Array.isArray((body as { technicians?: unknown[] }).technicians)
)
return NextResponse.json(
{ message: 'Invalid input: expecting an array of technicians.' },
{ status: 400 },
);
const technicians = (body as { technicians: unknown[] }).technicians;
if (!technicians.every(isTechnician))
return NextResponse.json(
{ message: 'Invalid input: missing name or status for a technician.' },
{ status: 400 },
);
await updateEmployeeStatusByName(technicians);
return NextResponse.json(
{ message: 'Technicians updated successfully.' },
{ status: 200 },
);
} catch (error) {
console.error('Error updating technicians:', error);
return NextResponse.json(
{ message: 'Internal server error' },
{ status: 500 },
);
}
};

View File

@ -1,47 +0,0 @@
"use server";
import { NextResponse } from 'next/server';
import { legacyUpdateEmployeeStatusByName } from '~/server/functions';
// Define the Technician type directly in the file
interface Technician {
name: string;
status: string;
}
// Type guard to check if an object is a Technician
const isTechnician = (technician: unknown): technician is Technician => {
if (typeof technician !== 'object' || technician === null) return false;
return 'name' in technician && typeof (technician as Technician).name === 'string' &&
'status' in technician && typeof (technician as Technician).status === 'string';
};
export const POST = async (request: Request) => {
try {
const url = new URL(request.url);
const apiKey = url.searchParams.get('apikey');
if (apiKey !== 'zAf4vYVN2pszrK') {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
const body: unknown = await request.json();
// Validate the body and its technicians property
if (typeof body !== 'object' || body === null || !Array.isArray((body as { technicians?: unknown[] }).technicians)) {
return NextResponse.json({ message: 'Invalid input: expecting an array of technicians.' }, { status: 400 });
}
const technicians = (body as { technicians: unknown[] }).technicians;
if (!technicians.every(isTechnician)) {
return NextResponse.json({ message: 'Invalid input: missing name or status for a technician.' }, { status: 400 });
}
await legacyUpdateEmployeeStatusByName(technicians);
return NextResponse.json({ message: 'Technicians updated successfully.' }, { status: 200 });
} catch (error) {
console.error('Error updating technicians:', error);
return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
}
};

View File

@ -1,18 +0,0 @@
"use server";
import { NextResponse } from 'next/server';
import { getEmployees } from '~/server/functions';
import { auth } from '~/auth';
export const GET = async () => {
try {
const session = await auth();
if (!session)
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
const employees = await getEmployees();
return NextResponse.json(employees, { status: 200 });
} catch (error) {
console.error('Error fetching employees:', error);
return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
}
};

View File

@ -1,30 +0,0 @@
"use server";
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { updateEmployeeStatus } from '~/server/functions';
import { auth } from '~/auth';
type UpdateStatusBody = {
employeeIds: string[];
newStatus: string;
};
export const POST = async (req: NextRequest) => {
const session = await auth();
if (!session)
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
const { employeeIds, newStatus } = await req.json() as UpdateStatusBody;
if (!Array.isArray(employeeIds) || typeof newStatus !== 'string') {
return NextResponse.json({ message: 'Invalid input' }, { status: 400 });
}
try {
await updateEmployeeStatus(employeeIds, newStatus);
return NextResponse.json({ message: 'Status updated successfully' }, { status: 200 });
} catch (error) {
console.error('Error updating status:', error);
return NextResponse.json({ message: 'Internal server error' }, { status: 500 });
}
};

51
src/app/layout.tsx Normal file → Executable file
View File

@ -1,36 +1,51 @@
import "~/styles/globals.css";
import { Inter as FontSans } from "next/font/google";
import { cn } from "~/lib/utils";
import { SessionProvider } from "next-auth/react";
import Sign_Out from "~/components/auth/Sign_Out";
import '~/styles/globals.css';
import { Inter as FontSans } from 'next/font/google';
import { cn } from '~/lib/utils';
import { SessionProvider } from 'next-auth/react';
import { TVModeProvider } from '~/components/context/TVModeContext';
import { type Metadata } from "next";
import { type Metadata } from 'next';
export const metadata: Metadata = {
title: "Tech Tracker",
description: "App used by COG IT employees to update their status throughout the day.",
icons: [{ rel: "icon", url: "/favicon.ico" }],
title: 'Tech Tracker',
description:
'App used by COG IT employees to \
update their status throughout the day.',
icons: [
{
rel: 'icon',
url: '/favicon.ico',
},
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
url: '/images/tech_tracker_favicon.png',
},
{
rel: 'apple-touch-icon',
url: '/imges/tech_tracker_appicon.png',
},
],
};
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
subsets: ['latin'],
variable: '--font-sans',
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<html lang='en'>
<body
className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable)}
'min-h-screen bg-background font-sans antialiased',
fontSans.variable,
)}
>
<SessionProvider>
<div className="absolute top-4 right-6">
<Sign_Out />
</div>
{children}
<TVModeProvider>{children}</TVModeProvider>
</SessionProvider>
</body>
</html>

23
src/app/page.tsx Normal file → Executable file
View File

@ -1,18 +1,23 @@
import { auth } from "~/auth";
import No_Session from "~/components/auth/No_Session";
import TT_Header from "~/components/ui/TT_Header";
import Techs from "~/components/ui/Techs";
'use server';
import { auth } from '~/auth';
import No_Session from '~/components/ui/No_Session';
import Header from '~/components/ui/Header';
import { getEmployees } from '~/server/functions';
import Tech_Table from '~/components/ui/Tech_Table';
export default async function HomePage() {
const session = await auth();
if (!session) {
return <No_Session />
return <No_Session />;
} else {
const employees = await getEmployees();
return (
<main className="min-h-screen bg-gradient-to-b
from-[#111111] to-[#212325]">
<TT_Header />
<Techs />
<main
className='min-h-screen
bg-gradient-to-b from-[#111111] to-[#212325]'
>
<Header />
<Tech_Table employees={employees} />
</main>
);
}

14
src/auth.ts Normal file → Executable file
View File

@ -1,6 +1,7 @@
import NextAuth from "next-auth"
import Entra from "next-auth/providers/microsoft-entra-id"
import NextAuth from 'next-auth';
import Entra from 'next-auth/providers/microsoft-entra-id';
import Authentik from 'next-auth/providers/authentik';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Entra({
@ -8,5 +9,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
tenantId: process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID,
}),
Authentik({
clientId: process.env.AUTH_AUTHENTIK_CLIENT_ID,
clientSecret: process.env.AUTH_AUTHENTIK_CLIENT_SECRET,
issuer: process.env.AUTH_AUTHENTIK_ISSUER,
}),
],
})
});

View File

@ -1,14 +0,0 @@
import Sign_In from "~/components/auth/Sign_In";
import TT_Header from "~/components/ui/TT_Header";
export default function No_Session() {
return (
<main className="w-full min-h-screen mx-auto text-center pt-10
bg-gradient-to-b from-[#111111] to-[#212325]">
<div className="pt-8 pb-4">
<TT_Header />
</div>
< Sign_In />
</main>
);
};

View File

@ -1,25 +0,0 @@
import Image from "next/image";
import { auth } from "~/auth"
import { signOut } from "~/auth"
export default async function Sign_Out() {
const session = await auth();
if (!session) {
return (<div/>);
} else {
// Add User profile picture next to Sign Out button
const pfp = session?.user?.image ? session.user.image : "/images/default_user_pfp.png";
return (
<form className="w-full flex flex-row pt-4 pr-8"
action={async () => {
"use server"
await signOut()
}}>
<Image src={pfp} alt="" width={35} height={35}
className="rounded-full border-2 border-white m-auto mr-4"
/>
<button type="submit" className="w-full">Sign Out</button>
</form>
);
}
};

View File

@ -0,0 +1,15 @@
import { signIn } from "next-auth/react";
import { Button } from "~/components/ui/shadcn/button";
export default function Sign_In() {
return (
<Button
onClick={() => signIn()}
className="bg-gradient-to-tl from-[#35363F] to=[#24191A] rounded-xl
px-4 py-2 md:py-2.5 font-semibold text-white hover:bg-gradient-to-tr
hover:from-[#35363F] hover:to-[#23242F]"
>
<h1 className="md:text-2xl my-auto font-semibold">Sign In</h1>
</Button>
);
};

View File

@ -0,0 +1,48 @@
import { signOut } from 'next-auth/react';
import { useSession } from 'next-auth/react';
import Image from 'next/image';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '~/components/ui/shadcn/dropdown-menu';
export default function Sign_Out() {
const { data: session } = useSession();
if (!session) {
return <div />;
} else {
const pfp = session?.user?.image
? session.user.image
: '/images/default_user_pfp.png';
const name = session?.user?.name ? session.user.name : 'Profile';
return (
<div className='m-auto mt-1'>
<DropdownMenu>
<DropdownMenuTrigger>
<Image
src={pfp}
alt=''
width={35}
height={35}
className='rounded-full border-2 border-white m-auto mr-1 md:mr-2
max-w-[25px] md:max-w-[35px]'
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>{name}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<button onClick={() => signOut()} className='w-full'>
Sign Out
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
}

View File

@ -0,0 +1,15 @@
import { signIn } from 'next-auth/react';
import { Button } from '~/components/ui/shadcn/button';
export default function Sign_In_Authentik() {
return (
<Button
onClick={() => signIn('authentik')}
className='bg-gradient-to-tl from-[#35363F] to=[#24191A] rounded-xl
px-4 py-2 md:py-2.5 font-semibold text-white hover:bg-gradient-to-tr
hover:from-[#35363F] hover:to-[#23242F]'
>
<h1 className='md:text-2xl my-auto font-semibold'>Sign In</h1>
</Button>
);
}

View File

@ -0,0 +1,15 @@
import { signIn } from 'next-auth/react';
import { Button } from '~/components/ui/shadcn/button';
export default function Sign_In_Microsoft() {
return (
<Button
onClick={() => signIn('microsoft-entra-id')}
className='bg-gradient-to-tl from-[#35363F] to=[#24191A] rounded-xl
px-4 py-2 md:py-2.5 font-semibold text-white hover:bg-gradient-to-tr
hover:from-[#35363F] hover:to-[#23242F]'
>
<h1 className='md:text-2xl my-auto font-semibold'>Sign In</h1>
</Button>
);
}

View File

@ -9,12 +9,12 @@ export default async function Sign_In() {
await signIn("microsoft-entra-id");
}}>
<button type="submit" className="flex flex-row mx-auto
bg-gradient-to-tl from-[#35363F] to=[#24191A] rounded-xl px-4 py-3 md:text-2xl
sm:text-xl font-semibold text-white hover:bg-gradient-to-tr hover:from-[#35363F] hover:to-[#23242F]">
bg-gradient-to-tl from-[#35363F] to=[#24191A] rounded-xl px-4 py-2 md:py-2.5
font-semibold text-white hover:bg-gradient-to-tr hover:from-[#35363F] hover:to-[#23242F]">
<Image src="/images/microsoft_logo.png" alt="Microsoft" width={35} height={35}
className="mr-2"
/>
<h1 className="text-2xl my-auto font-semibold">Sign In</h1>
<h1 className="md:text-2xl my-auto font-semibold">Sign In</h1>
</button>
</form>
);

View File

@ -0,0 +1,41 @@
import Image from 'next/image';
import { auth } from '~/auth';
import { signOut } from '~/auth';
export default async function Sign_Out() {
const session = await auth();
if (!session) {
return <div />;
} else {
// Add User profile picture next to Sign Out button
const pfp = session?.user?.image
? session.user.image
: '/images/default_user_pfp.png';
return (
<form
className='flex flex-row'
action={async () => {
'use server';
await signOut();
}}
>
<Image
src={pfp}
alt=''
width={35}
height={35}
className='rounded-full border-2 border-white m-auto mr-1 md:mr-2
max-w-[25px] md:max-w-[35px]'
/>
<button
type='submit'
className='w-full p-2 rounded-xl text-sm md:text-lg
bg-gradient-to-tl from-[#35363F] to=[#24191A]
hover:bg-gradient-to-tr hover:from-[#35363F] hover:to-[#23242F]'
>
Sign Out
</button>
</form>
);
}
}

View File

@ -0,0 +1,32 @@
import Image from 'next/image';
import { signIn } from '~/auth';
const Sign_In_Authentik = () => {
return (
<form
className='items-center justify-center mx-auto'
action={async () => {
'use server';
await signIn('authentik');
}}
>
<button
type='submit'
className='flex flex-col mx-auto text-center items-center
bg-gradient-to-tl from-[#35363F] to=[#24191A] rounded-xl px-4 py-2 md:py-2.5
font-semibold text-white hover:bg-gradient-to-tr hover:from-[#35363F] hover:to-[#23242F]'
>
<h1 className='md:text-2xl my-auto font-semibold mb-1'>
Sign In with
</h1>
<Image
src='/images/authentik_logo.svg'
alt='Microsoft'
width={150}
height={150}
/>
</button>
</form>
);
};
export default Sign_In_Authentik;

View File

@ -0,0 +1,30 @@
import Image from 'next/image';
import { signIn } from '~/auth';
export default async function Sign_In_Microsoft() {
return (
<form
className='items-center justify-center mx-auto'
action={async () => {
'use server';
await signIn('microsoft-entra-id');
}}
>
<button
type='submit'
className='flex flex-col mx-auto text-center items-center
bg-gradient-to-tl from-[#35363F] to=[#24191A] rounded-xl px-4 py-2 md:py-2.5
font-semibold text-white hover:bg-gradient-to-tr hover:from-[#35363F] hover:to-[#23242F]'
>
<h1 className='md:text-2xl my-auto font-semibold'>Sign In with</h1>
<Image
src='/images/microsoft_logo.svg'
alt='Microsoft'
width={150}
height={150}
className='ml-2'
/>
</button>
</form>
);
}

View File

@ -0,0 +1,32 @@
'use client';
import React, { createContext, useContext, useState } from 'react';
import type { ReactNode } from 'react';
interface TVModeContextProps {
tvMode: boolean;
toggleTVMode: () => void;
}
const TVModeContext = createContext<TVModeContextProps | undefined>(undefined);
export const TVModeProvider = ({ children }: { children: ReactNode }) => {
const [tvMode, setTVMode] = useState(false);
const toggleTVMode = () => {
setTVMode((prev) => !prev);
};
return (
<TVModeContext.Provider value={{ tvMode, toggleTVMode }}>
{children}
</TVModeContext.Provider>
);
};
export const useTVMode = () => {
const context = useContext(TVModeContext);
if (!context) {
throw new Error('useTVMode must be used within a TVModeProvider');
}
return context;
};

48
src/components/ui/Header.tsx Executable file
View File

@ -0,0 +1,48 @@
'use client';
import Image from 'next/image';
import Sign_Out from '~/components/auth/client/Sign_Out';
import TV_Toggle from '~/components/ui/TV_Toggle';
import { useTVMode } from '~/components/context/TVModeContext';
export default function Header() {
const { tvMode } = useTVMode();
if (tvMode) {
return (
<div className='absolute top-4 right-2'>
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4'>
<TV_Toggle />
</div>
</div>
);
} else {
return (
<header className='w-full py-2 pt-6 md:py-5'>
<div className='absolute top-4 right-6'>
<div className='flex flex-row my-auto items-center pt-2 pr-0 md:pt-4 md:pr-8'>
<TV_Toggle />
<Sign_Out />
</div>
</div>
<div
className='flex flex-row items-center text-center
justify-center sm:ml-0 p-4 mt-10 sm:mt-0'
>
<Image
src='/images/tech_tracker_logo.png'
alt='Tech Tracker Logo'
width={100}
height={100}
className='max-w-[40px] md:max-w-[120px]'
/>
<h1
className='title-text text-sm md:text-4xl lg:text-8xl
bg-gradient-to-r from-[#bec8e6] via-[#F0EEE4] to-[#FFF8E7]
font-bold pl-2 md:pl-12 text-transparent bg-clip-text'
>
Tech Tracker
</h1>
</div>
</header>
);
}
}

View File

@ -0,0 +1,182 @@
import React, { useState, useEffect } from 'react';
import Image from 'next/image';
import { ScrollArea } from '~/components/ui/shadcn/scroll-area';
import {
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '~/components/ui/shadcn/drawer';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '~/components/ui/shadcn/table';
import {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '~/components/ui/shadcn/pagination';
// Type definitions for Paginated History API
type HistoryEntry = {
name: string;
status: string;
updatedAt: Date;
};
type PaginatedHistory = {
data: HistoryEntry[];
meta: {
current_page: number;
per_page: number;
total_pages: number;
total_count: number;
};
};
type History_Drawer_Props = {
user_id: number;
};
const History_Drawer: React.FC<History_Drawer_Props> = ({ user_id }) => {
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [page, setPage] = useState<number>(1);
const [totalPages, setTotalPages] = useState<number>(1);
const perPage = 50;
const fetchHistory = async (currentPage: number, user_id: number) => {
try {
const response = await fetch(
`/api/get_paginated_history?user_id=${user_id}&page=${currentPage}&per_page=${perPage}`,
);
if (!response.ok) throw new Error('Failed to fetch history');
const data: PaginatedHistory =
(await response.json()) as PaginatedHistory;
setHistory(data.data);
setTotalPages(data.meta.total_pages);
} catch (error) {
console.error('Error fetching history:', error);
}
};
useEffect(() => {
fetchHistory(page, user_id).catch((error) => {
console.error('Error fetching history:', error);
});
}, [page, user_id]);
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
return (
<DrawerContent>
<DrawerHeader>
<DrawerTitle>
<div className='flex flex-row items-center text-center sm:justify-center sm:ml-0 py-4'>
<Image
src='/images/tech_tracker_logo.png'
alt='Tech Tracker Logo'
width={60}
height={60}
className='max-w-[40px] md:max-w-[120px]'
/>
<h1 className='title-text text-sm md:text-2xl lg:text-6xl bg-gradient-to-r from-[#bec8e6] via-[#F0EEE4] to-[#FFF8E7] font-bold pl-2 md:pl-4 text-transparent bg-clip-text'>
History
</h1>
</div>
</DrawerTitle>
</DrawerHeader>
<ScrollArea className='w-full sm:w-5/6 lg:w-5/12 m-auto h-80'>
<Table className='w-full m-auto'>
<TableHeader>
<TableRow>
<TableHead className='font-semibold lg:max-w-[100px]'>
Name
</TableHead>
<TableHead className='font-semibold lg:max-w-[100px]'>
Status
</TableHead>
<TableHead className='font-semibold lg:max-w-[100px] justify-end items-end text-right'>
Updated At
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{history.map((entry, index) => (
<TableRow key={index}>
<TableCell className='font-medium lg:max-w-[100px]'>
{entry.name}
</TableCell>
<TableCell className='font-medium lg:max-w-[100px] wrapword'>
{entry.status}
</TableCell>
<TableCell className='font-medium lg:max-w-[100px] justify-end items-end text-right'>
{new Date(entry.updatedAt).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
<DrawerFooter>
<Pagination>
<PaginationContent>
{page > 1 && (
<PaginationItem>
<PaginationPrevious
href='#'
onClick={(e) => {
e.preventDefault();
handlePageChange(page - 1);
}}
/>
</PaginationItem>
)}
{totalPages > 10 && (
<h3 className='text-center flex flex-row'>
Page
<h3 className='font-bold mx-1'>{page}</h3>
of
<h3 className='font-semibold ml-1'>{totalPages}</h3>
</h3>
)}
{totalPages <= 10 &&
Array.from({ length: totalPages }).map((_, idx) => (
<PaginationItem key={idx}>
<PaginationLink
href='#'
onClick={(e) => {
e.preventDefault();
handlePageChange(idx + 1);
}}
>
{idx + 1}
</PaginationLink>
</PaginationItem>
))}
{page < totalPages && (
<PaginationItem>
<PaginationNext
href='#'
onClick={(e) => {
e.preventDefault();
handlePageChange(page + 1);
}}
/>
</PaginationItem>
)}
</PaginationContent>
</Pagination>
<DrawerClose></DrawerClose>
</DrawerFooter>
</DrawerContent>
);
};
export default History_Drawer;

29
src/components/ui/Loading.tsx Executable file
View File

@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import { Progress } from '~/components/ui/shadcn/progress';
interface Loading_Props {
interval_amount: number;
}
const Loading: React.FC<Loading_Props> = ({ interval_amount }) => {
const [progress, setProgress] = React.useState(13);
React.useEffect(() => {
const interval = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
clearInterval(interval);
return 0;
}
return prev + interval_amount;
});
}, 50);
return () => clearInterval(interval);
});
return (
<div className='items-center justify-center w-1/3 m-auto pt-20'>
<Progress value={progress} />
</div>
);
};
export default Loading;

View File

@ -0,0 +1,49 @@
import Link from 'next/link';
import Image from 'next/image';
import Sign_In_Microsoft from '~/components/auth/server/microsoft/Sign_In';
import Sign_In_Authentik from '~/components/auth/server/authentik/Sign_In';
import Header from '~/components/ui/Header';
export default function No_Session() {
return (
<main
className='w-full min-h-screen mx-auto text-center pt-2 md:pt-10
bg-gradient-to-b from-[#111111] to-[#212325]'
>
<div className='md:w-2/3 pt-4 pb-2 md:pt-10 md:pb-4 m-auto'>
<Header />
</div>
<div className='mx-auto flex flex-col items-center justify-center'>
<div className='flex sm:flex-row flex-col p-4'>
<div className='p-4'>
<Sign_In_Microsoft />
</div>
<div className='p-4'>
<Sign_In_Authentik />
</div>
</div>
</div>
<div className='sm:absolute sm:bottom-6 sm:left-4 flex flex-row'>
<Link
href='https://git.gibbyb.com/gib/Tech_Tracker_Web'
className='text-center text-[16px] md:text-lg p-2 font-semibold
bg-gradient-to-tl from-[#35363F] to=[#24191A] rounded-xl hover:text-sky-200
hover:bg-gradient-to-tr hover:from-[#35363F] hover:to-[#23242F]
mx-auto flex flex-col items-center justify-center'
>
<h3 className='my-auto'>View Source Code on</h3>
<div className='flex flex-row px-2'>
<Image
src='/images/gitea_logo.svg'
alt='Gitea'
width={40}
height={40}
className='mr-2'
/>
<h3 className='my-auto text-2xl'>Gitea</h3>
</div>
</Link>
</div>
</main>
);
}

View File

@ -1,18 +0,0 @@
import Image from "next/image";
export default function TT_Header() {
return (
<header className="w-full py-5">
<div className="flex flex-row items-center text-center justify-center p-8">
<Image src="/images/tech_tracker_logo.png"
alt="Tech Tracker Logo" width={100} height={100}
/>
<h1 className="title-text text-8xl font-bold pl-12
bg-gradient-to-r from-[#bec8e6] via-[#F0EEE4] to-[#FFF8E7]
text-transparent bg-clip-text">
Tech Tracker
</h1>
</div>
</header>
);
};

29
src/components/ui/TV_Toggle.tsx Executable file
View File

@ -0,0 +1,29 @@
'use client';
import Image from 'next/image';
import { useTVMode } from '~/components/context/TVModeContext';
import { useSession } from 'next-auth/react';
export default function TV_Toggle() {
const { tvMode, toggleTVMode } = useTVMode();
const { data: session } = useSession();
if (!session) return <div />;
return (
<button onClick={toggleTVMode} className='mr-4 mt-1'>
{tvMode ? (
<Image
src='/images/exit_fullscreen.svg'
alt='Exit TV Mode'
width={25}
height={25}
/>
) : (
<Image
src='/images/fullscreen.svg'
alt='Enter TV Mode'
width={25}
height={25}
/>
)}
</button>
);
}

View File

@ -1,181 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
// Define the Employee interface to match data fetched on the server
interface Employee {
id: number;
name: string;
status: string;
updatedAt: Date;
}
export default function Table({ employees }: { employees: Employee[] }) {
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [status, setStatus] = useState('');
const [employeeData, setEmployeeData] = useState(employees);
useEffect(() => {
// Refresh employee data if needed after state updates
setEmployeeData(employees);
}, [employees]);
const fetchEmployees = useCallback(async (): Promise<Employee[]> => {
const res = await fetch('/api/v2/get_employees', {
method: 'GET',
headers: {
'Authorization': `Bearer ${process.env.API_KEY}`
}
});
return res.json() as Promise<Employee[]>;
}, []);
useEffect(() => {
const fetchAndUpdateEmployees = async () => {
const updatedEmployees = await fetchEmployees();
setEmployeeData(updatedEmployees);
};
fetchAndUpdateEmployees()
.catch((error) => {
console.error('Error fetching employees:', error);
});
const intervalId = setInterval(() => {
(async () => {
await fetchAndUpdateEmployees();
})()
.catch((error) => {
console.error('Error fetching employees:', error);
});
}, 10000); // Poll every 10 seconds
return () => clearInterval(intervalId); // Clear interval on component unmount
}, [fetchEmployees]);
const handleCheckboxChange = (id: number) => {
setSelectedIds((prevSelected) =>
prevSelected.includes(id)
? prevSelected.filter((prevId) => prevId !== id)
: [...prevSelected, id]
);
};
const handleSelectAllChange = () => {
setSelectAll(!selectAll);
if (!selectAll) {
const allIds = employees.map((employee) => employee.id);
setSelectedIds(allIds);
} else {
setSelectedIds([]);
}
};
useEffect(() => {
if (selectedIds.length === employeeData.length && employeeData.length > 0) {
setSelectAll(true);
} else {
setSelectAll(false);
}
}, [selectedIds, employeeData]);
const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setStatus(e.target.value);
};
const handleSubmit = async () => {
if (selectedIds.length > 0 && status.trim() !== '') {
await fetch('/api/v2/update_status', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.API_KEY}`
},
body: JSON.stringify({ employeeIds: selectedIds, newStatus: status }),
});
// Optionally refresh data on the client-side after update
const updatedEmployees = await fetchEmployees();
setEmployeeData(updatedEmployees);
setSelectedIds([]);
setStatus('');
}
};
const handleKeyPress = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
await handleSubmit();
}
};
const formatTime = (timestamp: Date) => {
const date = new Date(timestamp);
const time = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: 'numeric',
});
const day = date.getDate();
const month = date.toLocaleString('default', { month: 'long' });
return `${time} - ${month} ${day}`;
};
return (
<div>
<table className="techtable rounded-2xl w-5/6 m-auto text-center text-[42px]">
<thead className="bg-gradient-to-b from-[#282828] to-[#383838]">
<tr>
<th className="tabletitles p-4 border border-[#3e4446] text-[48px]">
<input
type="checkbox"
className="m-0 cursor-pointer transform scale-150"
checked={selectAll}
onChange={handleSelectAllChange}
/>
</th>
<th className="tabletitles p-2 border border-[#3e4446] text-[48px]">Name</th>
<th className="tabletitles p-2 border border-[#3e4446] text-[48px]">Status</th>
<th className="tabletitles p-2 border border-[#3e4446] text-[48px]">Updated At</th>
</tr>
</thead>
<tbody>
{employeeData.map((employee) => (
<tr className="even:bg-gradient-to-br from-[#272727] to-[#313131]
odd:bg-gradient-to-bl odd:from-[#252525] odd:to-[#212125]" key={employee.id}>
<td className="p-1 border border-[#3e4446]">
<input
type="checkbox"
className="m-0 cursor-pointer transform scale-150"
checked={selectedIds.includes(employee.id)}
onChange={() => handleCheckboxChange(employee.id)}
/>
</td>
<td className="n-column px-1 py-5 border border-[#3e4446]">{employee.name}</td>
<td className="s-column px-1 py-5 border border-[#3e4446]">{employee.status}</td>
<td className="ua-column px-1 py-5 border border-[#3e4446]">{formatTime(employee.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
<div className="m-auto flex flex-row items-center justify-center py-5">
<input
type="text"
placeholder="New Status"
className="min-w-[120px] lg:min-w-[400px] bg-[#F9F6EE] py-2 px-3 border-none rounded-xl text-[#111111] lg:text-2xl"
value={status}
onChange={handleStatusChange}
onKeyDown={handleKeyPress}
/>
<button
type="submit"
className="min-w-[100px] lg:min-w-[160px] m-2 p-2 border-none rounded-xl text-center
font-semibold lg:text-2xl hover:text-slate-300
hover:bg-gradient-to-bl hover:from-[#484848] hover:to-[#333333]
bg-gradient-to-br from-[#595959] to-[#444444]"
onClick={handleSubmit}
>
Update
</button>
</div>
</div>
);
}

272
src/components/ui/Tech_Table.tsx Executable file
View File

@ -0,0 +1,272 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useSession } from 'next-auth/react';
import Loading from '~/components/ui/Loading';
import { useTVMode } from '~/components/context/TVModeContext';
import { Drawer, DrawerTrigger } from '~/components/ui/shadcn/drawer';
import { ScrollArea } from '~/components/ui/shadcn/scroll-area';
import History_Drawer from '~/components/ui/History_Drawer';
type Employee = {
id: number;
name: string;
status: string;
updatedAt: Date;
};
export default function Tech_Table({ employees }: { employees: Employee[] }) {
const { data: session, status } = useSession();
const { tvMode } = useTVMode();
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [employeeStatus, setStatus] = useState('');
const [employeeData, setEmployeeData] = useState(employees);
const [selectedUserId, setSelectedUserId] = useState(-1);
const fetch_employees = useCallback(async (): Promise<Employee[]> => {
const res = await fetch('/api/get_technicians', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
},
});
return res.json() as Promise<Employee[]>;
}, []);
const update_status = async () => {
if (!session) {
alert('You must be signed in to update status.');
return;
} else if (selectedIds.length === 0 && employeeStatus.trim() !== '') {
const users_name = session.user?.name ?? '';
const name_arr = users_name.split(' ');
const lname = name_arr[name_arr.length - 1] ?? '';
const cur_user = employees.find((employee) =>
employee.name.includes(lname),
);
if (cur_user) {
await fetch('/api/update_status_by_id', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.API_KEY}`,
},
body: JSON.stringify({
employeeIds: [cur_user.id],
newStatus: employeeStatus,
}),
});
}
} else if (employeeStatus.trim() !== '') {
await fetch('/api/update_status_by_id', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.API_KEY}`,
},
body: JSON.stringify({
employeeIds: selectedIds,
newStatus: employeeStatus,
}),
});
}
const updatedEmployees = await fetch_employees();
setEmployeeData(updatedEmployees);
setSelectedIds([]);
setStatus('');
};
const handleCheckboxChange = (id: number) => {
setSelectedIds((prevSelected) =>
prevSelected.includes(id)
? prevSelected.filter((prevId) => prevId !== id)
: [...prevSelected, id],
);
};
const handleSelectAllChange = () => {
setSelectAll(!selectAll);
if (!selectAll) {
const allIds = employees.map((employee) => employee.id);
setSelectedIds(allIds);
} else {
setSelectedIds([]);
}
};
const handleStatusChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setStatus(e.target.value);
};
const handleKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
await update_status();
}
};
const handleStatusClick = (id: number) => {
setSelectedUserId(id);
};
const formatTime = (timestamp: Date) => {
const date = new Date(timestamp);
const time = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: 'numeric',
});
const day = date.getDate();
const month = date.toLocaleString('default', { month: 'long' });
return `${time} - ${month} ${day}`;
};
useEffect(() => {
if (status !== 'loading') {
setLoading(false);
}
}, [status]);
useEffect(() => {
setEmployeeData(employees);
}, [employees]);
useEffect(() => {
const fetchAndUpdateEmployees = async () => {
const updatedEmployees = await fetch_employees();
setEmployeeData(updatedEmployees);
};
fetchAndUpdateEmployees().catch((error) => {
console.error('Error fetching employees:', error);
});
const intervalId = setInterval(() => {
(async () => {
await fetchAndUpdateEmployees();
})().catch((error) => {
console.error('Error fetching employees:', error);
});
}, 10000);
return () => clearInterval(intervalId);
}, [fetch_employees]);
useEffect(() => {
if (selectedIds.length === employeeData.length && employeeData.length > 0) {
setSelectAll(true);
} else {
setSelectAll(false);
}
}, [selectedIds, employeeData]);
if (loading) return <Loading interval_amount={3} />;
else {
return (
<div className={`${tvMode ? 'content-fullscreen' : ''}`}>
{tvMode && <div className='w-full tablefill'></div>}
<table
className={`techtable m-auto text-center text-[42px]
${tvMode ? 'techtable-fullscreen' : 'w-5/6'}`}
>
<thead
className='tabletitles border border-[#3e4446] bg-gradient-to-b
from-[#282828] to-[#383838] text-[48px]'
>
<tr>
{!tvMode && (
<th className='py-3 px-3 md:px-6 border border-[#3e4446]'>
<input
type='checkbox'
className='m-auto cursor-pointer md:scale-150'
checked={selectAll}
onChange={handleSelectAllChange}
/>
</th>
)}
<th className='border border-[#3e4446] py-3'>Name</th>
<th className='border border-[#3e4446] py-3'>
<Drawer>
<DrawerTrigger>Status</DrawerTrigger>
<History_Drawer user_id={-1} />
</Drawer>
</th>
<th className='border border-[#3e4446] py-3'>Updated At</th>
</tr>
</thead>
<tbody>
{employeeData.map((employee) => (
<tr
className='even:bg-gradient-to-br from-[#272727] to-[#313131]
odd:bg-gradient-to-bl odd:from-[#252525] odd:to-[#212125]'
key={employee.id}
>
{!tvMode && (
<td className='p-1 border border-[#3e4446]'>
<input
type='checkbox'
className='m-auto cursor-pointer md:scale-150'
checked={selectedIds.includes(employee.id)}
onChange={() => handleCheckboxChange(employee.id)}
/>
</td>
)}
<td className='n-column px-1 md:py-3 border border-[#3e4446]'>
{employee.name}
</td>
<td
className='s-column max-w-[700px] px-1 md:py-3 border
border-[#3e4446] wrapword max-h-0'
>
<ScrollArea className='w-full m-auto h-[60px]'>
<Drawer>
<DrawerTrigger>
<button onClick={() => handleStatusClick(employee.id)}>
{employee.status}
</button>
</DrawerTrigger>
{selectedUserId !== -1 && (
<History_Drawer
key={selectedUserId}
user_id={selectedUserId}
/>
)}
</Drawer>
</ScrollArea>
</td>
<td className='ua-column px-1 md:py-3 border border-[#3e4446]'>
{formatTime(employee.updatedAt)}
</td>
</tr>
))}
</tbody>
</table>
{!tvMode && (
<div className='m-auto flex flex-row items-center justify-center py-5'>
<input
autoFocus
type='text'
placeholder='New Status'
className='min-w-[120px] lg:min-w-[400px] bg-[#F9F6EE] py-2 px-3
border-none rounded-xl text-[#111111] lg:text-2xl'
value={employeeStatus}
onChange={handleStatusChange}
onKeyDown={handleKeyDown}
/>
<button
type='submit'
className='min-w-[100px] lg:min-w-[160px] m-2 p-2 border-none rounded-xl
text-center font-semibold lg:text-2xl hover:text-slate-300
hover:bg-gradient-to-bl hover:from-[#484848] hover:to-[#333333]
bg-gradient-to-br from-[#595959] to-[#444444]'
onClick={update_status}
>
Update
</button>
</div>
)}
</div>
);
}
}

View File

@ -1,8 +0,0 @@
import { getEmployees } from "~/server/functions";
import Table from "~/components/ui/Table";
export default async function Techs() {
const employees = await getEmployees();
return <Table employees={employees} />;
};

View File

@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '~/lib/utils';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className='h-full w-full rounded-[inherit]'>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className='relative flex-1 rounded-full bg-border' />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '~/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm ' +
'font-medium ring-offset-background transition-colors focus-visible:outline-none ' +
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ' +
'disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
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}
/>
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@ -0,0 +1,75 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { cn } from '~/lib/utils';
import { buttonVariants } from '~/components/ui/shadcn/button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell:
'h-9 w-9 text-center text-sm p-0 relative ' +
'[&:has([aria-selected].day-range-end)]:rounded-r-md ' +
'[&:has([aria-selected].day-outside)]:bg-accent/50 ' +
'[&:has([aria-selected])]:bg-accent ' +
'first:[&:has([aria-selected])]:rounded-l-md ' +
'last:[&:has([aria-selected])]:rounded-r-md ' +
'focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary ' +
'hover:text-primary-foreground focus:bg-primary ' +
'focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 ' +
'aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
// @ts-expect-error - I didn't even write this code man cmon
IconLeft: ({ ...props }) => <ChevronLeft className='h-4 w-4' />,
IconRight: ({ ...props }) => <ChevronRight className='h-4 w-4' />,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };

View File

@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '~/lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background ' +
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ' +
'focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
'data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<Check className='h-4 w-4' />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@ -0,0 +1,81 @@
'use client';
import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { cn } from '~/lib/utils';
import { Button } from '~/components/ui/shadcn/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '~/components/ui/shadcn/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '~/components/ui/shadcn/popover';
// Define the type correctly as an array of objects
type ListItem = {
value: string;
label: string;
};
type ComboboxDemoProps = {
listItems: ListItem[];
};
export function ComboboxDemo({ listItems }: ComboboxDemoProps) {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState('');
const selectedItem =
listItems.find((item) => item.value === value)?.label ??
'Select listItem...';
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='w-[200px] justify-between'
>
{selectedItem}
<ChevronsUpDown className='ml-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[200px] p-0'>
<Command>
<CommandInput placeholder='Search listItem...' />
<CommandEmpty>No Item found.</CommandEmpty>
<CommandList>
<CommandGroup>
{listItems.map((listItem) => (
<CommandItem
key={listItem.value}
value={listItem.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? '' : currentValue);
setOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === listItem.value ? 'opacity-100' : 'opacity-0',
)}
/>
{listItem.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,170 @@
'use client';
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '~/lib/utils';
import { Dialog, DialogContent } from '~/components/ui/shadcn/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden ' +
'rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className='overflow-hidden p-0 shadow-lg'>
<Command
className='[&_[cmdk-group-heading]]:px-2
[&_[cmdk-group-heading]]:font-medium
[&_[cmdk-group-heading]]:text-muted-foreground
[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2
[&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5
[&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3
[&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'
>
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className='flex items-center border-b px-3' cmdk-input-wrapper=''>
<Search className='mr-2 h-4 w-4 shrink-0 opacity-50' />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm ' +
'outline-none placeholder:text-muted-foreground ' +
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className='py-6 text-center text-sm'
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 ' +
'[&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs ' +
'[&_[cmdk-group-heading]]:font-medium ' +
'[&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm ' +
'px-2 py-1.5 text-sm outline-none aria-selected:bg-accent ' +
'aria-selected:text-accent-foreground ' +
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@ -0,0 +1,41 @@
'use client';
import * as React from 'react';
import { format } from 'date-fns';
import { Calendar as CalendarIcon } from 'lucide-react';
import { cn } from '~/lib/utils';
import { Button } from '~/components/ui/shadcn/button';
import { Calendar } from '~/components/ui/shadcn/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '~/components/ui/shadcn/popover';
export function DatePickerDemo() {
const [date, setDate] = React.useState<Date>();
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant={'outline'}
className={cn(
'w-[280px] justify-start text-left font-normal',
!date && 'text-muted-foreground',
)}
>
<CalendarIcon className='mr-2 h-4 w-4' />
{date ? format(date, 'PPP') : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={date}
onSelect={setDate}
//initialFocus // Causes an error idk if it's needed
/>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,132 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '~/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
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,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] ' +
'translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 ' +
'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-[state=closed]:slide-out-to-left-1/2 ' +
'data-[state=closed]:slide-out-to-top-[48%] ' +
'data-[state=open]:slide-in-from-left-1/2 ' +
'data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.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-accent data-[state=open]:text-muted-foreground'
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className,
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@ -0,0 +1,118 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '~/lib/utils';
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = 'Drawer';
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/80', className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
className,
)}
{...props}
>
<div className='mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted' />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = 'DrawerContent';
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
{...props}
/>
);
DrawerHeader.displayName = 'DrawerHeader';
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
DrawerFooter.displayName = 'DrawerFooter';
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className,
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@ -0,0 +1,215 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '~/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm ' +
'px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className='ml-auto h-4 w-4' />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<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',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-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',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center 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',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<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,
)}
checked={checked}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Check className='h-4 w-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<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,
)}
{...props}
>
<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' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', 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}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

171
src/components/ui/shadcn/form.tsx Executable file
View File

@ -0,0 +1,171 @@
'use client';
import * as React from 'react';
import type * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
import { Controller, FormProvider, useFormContext } from 'react-hook-form';
import { cn } from '~/lib/utils';
import { Label } from '~/components/ui/shadcn/label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
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>
);
});
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', 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;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@ -0,0 +1,27 @@
import * as React from 'react';
import { cn } from '~/lib/utils';
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 ' +
'py-2 text-sm ring-offset-background file:border-0 file:bg-transparent ' +
'file:text-sm file:font-medium placeholder:text-muted-foreground ' +
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ' +
'focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };

View File

@ -0,0 +1,27 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '~/lib/utils';
const labelVariants = cva(
'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>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,118 @@
import * as React from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '~/lib/utils';
import { buttonVariants } from '~/components/ui/shadcn/button';
import type { ButtonProps } from '~/components/ui/shadcn/button';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role='navigation'
aria-label='pagination'
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
));
PaginationContent.displayName = 'PaginationContent';
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
));
PaginationItem.displayName = 'PaginationItem';
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>;
const PaginationLink = ({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label='Go to previous page'
size='default'
className={cn('gap-1 pl-2.5', className)}
{...props}
>
<ChevronLeft className='h-4 w-4' />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label='Go to next page'
size='default'
className={cn('gap-1 pr-2.5', className)}
{...props}
>
<span>Next</span>
<ChevronRight className='h-4 w-4' />
</PaginationLink>
);
PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>More pages</span>
</span>
);
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@ -0,0 +1,36 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '~/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md ' +
'outline-none 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',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '~/lib/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
className='h-full w-full flex-1 bg-primary transition-all'
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from 'lucide-react';
import { cn } from '~/lib/utils';
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('grid gap-2', className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ' +
'ring-offset-background focus:outline-none focus-visible:ring-2 ' +
'focus-visible:ring-ring focus-visible:ring-offset-2 ' +
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className='flex items-center justify-center'>
<Circle className='h-2.5 w-2.5 fill-current text-current' />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '~/lib/utils';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className='h-full w-full rounded-[inherit]'>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className='relative flex-1 rounded-full bg-border' />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@ -0,0 +1,171 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '~/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border ' +
'border-input bg-background px-3 py-2 text-sm ring-offset-background ' +
'placeholder:text-muted-foreground focus:outline-none focus:ring-2 ' +
'focus:ring-ring focus:ring-offset-2 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' />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUp className='h-4 w-4' />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDown className='h-4 w-4' />
</SelectPrimitive.ScrollDownButton>
));
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) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-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',
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}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full ' +
'min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm ' +
'py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent ' +
'focus:text-accent-foreground 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'>
<SelectPrimitive.ItemIndicator>
<Check className='h-4 w-4' />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
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}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@ -0,0 +1,36 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '~/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full ' +
'border-2 border-transparent 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,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ' +
'ring-0 transition-transform data-[state=checked]:translate-x-5 ' +
'data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@ -0,0 +1,118 @@
import * as React from 'react';
import { cn } from '~/lib/utils';
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className='relative w-full overflow-auto'>
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
));
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium ' +
'text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@ -0,0 +1,31 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className='toaster group'
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background ' +
'group-[.toaster]:text-foreground ' +
'group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@ -0,0 +1,59 @@
'use client';
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '~/lib/utils';
import { toggleVariants } from '~/components/ui/shadcn/toggle';
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('flex items-center justify-center gap-1', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant ?? variant,
size: context.size ?? size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '~/lib/utils';
const toggleVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ' +
'ring-offset-background transition-colors hover:bg-muted ' +
'hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 ' +
'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none ' +
'disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent hover:bg-accent ' +
'hover:text-accent-foreground',
},
size: {
default: 'h-10 px-3',
sm: 'h-9 px-2.5',
lg: 'h-11 px-5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };

View File

@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '~/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 ' +
'text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 ' +
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 ' +
'data-[state=closed]:zoom-out-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',
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

21
src/env.js Normal file → Executable file
View File

@ -1,5 +1,5 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
/**
@ -9,14 +9,18 @@ export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
.enum(['development', 'test', 'production'])
.default('development'),
API_KEY: z.string(),
AUTH_TRUST_HOST: z.boolean().default(false),
AUTH_URL: z.string(),
AUTH_TRUST_HOST: z.coerce.boolean().default(true),
AUTH_SECRET: z.string(),
AUTH_MICROSOFT_ENTRA_ID_ID: z.string(),
AUTH_MICROSOFT_ENTRA_ID_SECRET: z.string(),
AUTH_MICROSOFT_ENTRA_ID_TENANT_ID: z.string(),
AUTH_AUTHENTIK_CLIENT_ID: z.string(),
AUTH_AUTHENTIK_CLIENT_SECRET: z.string(),
AUTH_AUTHENTIK_ISSUER: z.string(),
},
/**
@ -36,11 +40,16 @@ export const env = createEnv({
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
API_KEY: process.env.API_KEY,
AUTH_URL: process.env.AUTH_URL,
AUTH_TRUST_HOST: process.env.AUTH_TRUST_HOST,
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_MICROSOFT_ENTRA_ID_ID: process.env.AUTH_MICROSOFT_ENTRA_ID_ID,
AUTH_MICROSOFT_ENTRA_ID_SECRET: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
AUTH_MICROSOFT_ENTRA_ID_TENANT_ID: process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID,
AUTH_MICROSOFT_ENTRA_ID_TENANT_ID:
process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID,
AUTH_AUTHENTIK_CLIENT_ID: process.env.AUTH_AUTHENTIK_CLIENT_ID,
AUTH_AUTHENTIK_CLIENT_SECRET: process.env.AUTH_AUTHENTIK_CLIENT_SECRET,
AUTH_AUTHENTIK_ISSUER: process.env.AUTH_AUTHENTIK_ISSUER,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially

6
src/lib/utils.ts Normal file → Executable file
View File

@ -1,6 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}

2
src/middleware.ts Normal file → Executable file
View File

@ -1 +1 @@
export { auth as middleware } from "~/auth"
export { auth as middleware } from '~/auth';

12
src/server/db/index.ts Normal file → Executable file
View File

@ -1,8 +1,8 @@
import { drizzle } from "drizzle-orm/mysql2";
import { createPool, type Pool } from "mysql2/promise";
import { drizzle } from 'drizzle-orm/mysql2';
import { createPool, type Pool } from 'mysql2/promise';
import { env } from "~/env";
import * as schema from "./schema";
import { env } from '~/env';
import * as schema from './schema';
/**
* Cache the database connection in development. This avoids creating a new connection on every HMR
@ -13,6 +13,6 @@ const globalForDb = globalThis as unknown as {
};
const conn = globalForDb.conn ?? createPool({ uri: env.DATABASE_URL });
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
if (env.NODE_ENV !== 'production') globalForDb.conn = conn;
export const db = drizzle(conn, { schema, mode: "default" });
export const db = drizzle(conn, { schema, mode: 'default' });

38
src/server/db/schema.ts Normal file → Executable file
View File

@ -1,32 +1,26 @@
// https://orm.drizzle.team/docs/sql-schema-declaration
import { sql } from "drizzle-orm";
import { sql } from 'drizzle-orm';
import {
bigint,
mysqlTableCreator,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
} from 'drizzle-orm/mysql-core';
export const createTable = mysqlTableCreator((name) => `${name}`);
export const users = createTable(
"users",
{
id: bigint("id", {mode: "number"}).primaryKey().autoincrement(),
name: varchar("name", { length: 256 }).notNull(),
status: varchar("status", { length: 256 }).notNull(),
updatedAt: timestamp("updatedAt")
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
},
);
export const users = createTable('users', {
id: bigint('id', { mode: 'number' }).primaryKey().autoincrement(),
name: varchar('name', { length: 256 }).notNull(),
status: varchar('status', { length: 256 }).notNull(),
updatedAt: timestamp('updatedAt')
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
});
export const history = createTable(
"history",
{
id: bigint("id", {mode: "number"}).primaryKey().autoincrement(),
user_id: bigint("user_id", {mode: "number"}).references(() => users.id),
status: varchar("status", { length: 256 }).notNull(),
updatedAt: timestamp("updatedAt").notNull(),
},
);
export const history = createTable('history', {
id: bigint('id', { mode: 'number' }).primaryKey().autoincrement(),
user_id: bigint('user_id', { mode: 'number' }).references(() => users.id),
status: varchar('status', { length: 256 }).notNull(),
updatedAt: timestamp('updatedAt').notNull(),
});

170
src/server/functions.ts Normal file → Executable file
View File

@ -1,114 +1,135 @@
import "server-only";
import { db } from "~/server/db";
import { sql } from "drizzle-orm";
import 'server-only';
import { db } from '~/server/db';
import { sql } from 'drizzle-orm';
// Function to Get Employees
export const getEmployees = async () => {
return await db.query.users.findMany({
orderBy: (model, { asc }) => asc(model.id),
});
};
// Function to Update Employee Status using Raw SQL
export const updateEmployeeStatus = async (employeeIds: string[], newStatus: string) => {
// Update Employee Status uses Raw SQL because Drizzle ORM doesn't support
// update with MySQL
export const updateEmployeeStatus = async (
employeeIds: string[],
newStatus: string,
) => {
try {
// Convert array of ids to a format suitable for SQL query (comma-separated string)
const idList = employeeIds.map(id => parseInt(id, 10));
const updatedAt = new Date();
// Prepare the query using drizzle-orm's template-like syntax for escaping variables
const idList = employeeIds.map((id) => parseInt(id, 10));
let updatedAt = new Date();
// Not sure why but localhost is off by 5 hours
if (process.env.NODE_ENV === 'development')
updatedAt = new Date(updatedAt.setHours(updatedAt.getUTCHours()) + 5);
const query = sql`
UPDATE users
SET status = ${newStatus}, updatedAt = ${updatedAt}
WHERE id IN ${idList}
`;
// Execute the query
await db.execute(query);
return { success: true };
} catch (error) {
console.error("Error updating employee status:", error);
throw new Error("Failed to update status");
console.error('Error updating employee status:', error);
throw new Error('Failed to update status');
}
};
// Legacy Functions for Legacy API for iOS App
// Function to Update Employee Status by Name using Raw SQL
export const updateEmployeeStatusByName = async (
technicians: { name: string; status: string }[],
) => {
try {
for (const technician of technicians) {
const { name, status } = technician;
const query = sql`
UPDATE users
SET status = ${status}, updatedAt = ${new Date()}
WHERE name = ${name}
`;
await db.execute(query);
}
return { success: true };
} catch (error) {
console.error('Error updating employee status by name:', error);
throw new Error('Failed to update status by name');
}
};
// Type definitions
interface HistoryEntry {
// Type definitions for Paginated History API
type HistoryEntry = {
name: string;
status: string;
time: Date;
}
interface PaginatedHistory {
updatedAt: Date;
};
type PaginatedHistory = {
data: HistoryEntry[];
meta: {
current_page: number;
per_page: number;
total_pages: number;
total_count: number;
}
}
// Function to Convert Date to UTC
const convertToUTC = (date: Date): Date => {
const utcDate = new Date(date.setHours(date.getUTCHours() - 12));
return utcDate;
}
export const legacyGetEmployees = async () => {
const employees = await db.query.users.findMany({
orderBy: (model, { asc }) => asc(model.id),
});
if (employees.length === 0) {
return [];
}
for (const employee of employees) {
const date = new Date(employee.updatedAt);
employee.updatedAt = convertToUTC(date);
}
return employees;
};
};
// Function to Get History Data with Pagination using Raw SQL
export const legacyGetHistory = async (page: number, perPage: number): Promise<PaginatedHistory> => {
export const get_history = async (
user_id: number,
page: number,
perPage: number,
): Promise<PaginatedHistory> => {
const offset = (page - 1) * perPage;
// Raw SQL queries
const historyQuery = sql`
let historyQuery = sql`
SELECT u.name, h.status, h.updatedAt
FROM history h
JOIN users u ON h.user_id = u.id
WHERE h.user_id = ${user_id}
ORDER BY h.id DESC
LIMIT ${perPage} OFFSET ${offset}
`;
const countQuery = sql`
let countQuery = sql`
SELECT COUNT(*) AS total_count
FROM history
WHERE user_id = ${user_id}
`;
const [historyResults, countResults] = await Promise.all([
db.execute(historyQuery),
if (user_id === -1) {
historyQuery = sql`
SELECT u.name, h.status, h.updatedAt
FROM history h
JOIN users u ON h.user_id = u.id
ORDER BY h.id DESC
LIMIT ${perPage} OFFSET ${offset}
`;
countQuery = sql`
SELECT COUNT(*) AS total_count
FROM history
`;
}
const [historyresults, countresults] = await Promise.all([
db.execute(historyQuery),
db.execute(countQuery),
]);
// Safely cast results
const historyRows = historyResults[0] as unknown as { name: string, status: string, updatedAt: Date }[];
const countRow = countResults[0] as unknown as { total_count: number }[];
const totalCount = countRow[0]?.total_count ?? 0;
const historyrows = historyresults[0] as unknown as {
name: string;
status: string;
updatedAt: Date;
}[];
const countrow = countresults[0] as unknown as { total_count: number }[];
const totalCount = countrow[0]?.total_count ?? 0;
const totalPages = Math.ceil(totalCount / perPage);
// Format and map results
const formattedResults: HistoryEntry[] = historyRows.map(row => ({
let formattedResults: HistoryEntry[] = historyrows.map((row) => ({
name: row.name,
status: row.status,
time: new Date(row.updatedAt),
updatedAt: new Date(row.updatedAt),
}));
if (process.env.NODE_ENV === 'development') {
formattedResults = formattedResults.map((entry) => ({
...entry,
updatedAt: new Date(
entry.updatedAt.setHours(entry.updatedAt.getUTCHours() + 14),
),
}));
}
return {
data: formattedResults,
meta: {
@ -116,29 +137,6 @@ export const legacyGetHistory = async (page: number, perPage: number): Promise<P
per_page: perPage,
total_pages: totalPages,
total_count: totalCount,
}
},
};
};
// Function to Update Employee Status by Name using Raw SQL
export const legacyUpdateEmployeeStatusByName = async (technicians: { name: string, status: string }[]) => {
try {
// Prepare and execute the queries for each technician
for (const technician of technicians) {
const { name, status } = technician;
const utcdate: Date = convertToUTC(new Date());
const query = sql`
UPDATE users
SET status = ${status}, updatedAt = ${utcdate}
WHERE name = ${name}
`;
await db.execute(query);
}
return { success: true };
} catch (error) {
console.error("Error updating employee status by name:", error);
throw new Error("Failed to update status by name");
}
};

24
src/styles/globals.css Normal file → Executable file
View File

@ -110,12 +110,30 @@
@media (max-width: 1000px) {
.title-text {
font-size: 32px;
font-size: 24px;
}
.techtable {
font-size: 20px;
font-size: 16px;
}
.tabletitles {
font-size:24px;
font-size:18px;
}
}
.content-fullscreen {
width: 90vw;
height: 80vh;
margin: auto;
}
.techtable-fullscreen {
width: 100%;
/*height: 100%;*/
height: 80vh;
}
.tablefill {
height: 10vh;
}

82
tailwind.config.ts Normal file → Executable file
View File

@ -1,84 +1,84 @@
import type { Config } from "tailwindcss"
import { fontFamily } from "tailwindcss/defaultTheme"
import type { Config } from 'tailwindcss';
import { fontFamily } from 'tailwindcss/defaultTheme';
const config = {
darkMode: ["class"],
darkMode: ['class'],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
],
prefix: '',
theme: {
container: {
center: true,
padding: "2rem",
padding: '2rem',
screens: {
"2xl": "1400px",
'2xl': '1400px',
},
},
extend: {
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
sans: ['var(--font-sans)', ...fontFamily.sans],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config
plugins: [require('tailwindcss-animate')],
} satisfies Config;
export default config
export default config;

Some files were not shown because too many files have changed in this diff Show More