Compare commits
51 Commits
68644842fc
...
master
Author | SHA1 | Date | |
---|---|---|---|
e2e6e4a70a | |||
bc9417d36b | |||
be6f80a997 | |||
902eee260b | |||
5295d06686 | |||
ed4d51400c | |||
7d637b7401 | |||
76c1c40e74 | |||
fe3dabe3b7 | |||
0d5197dd94 | |||
787fcb0031 | |||
aab4efbb00 | |||
744328c156 | |||
f3bb15aaeb | |||
d8b1aa242a | |||
eab772ad99 | |||
0fbd0f7182 | |||
6b80930a86 | |||
44497ebe7b | |||
b14383f8fd | |||
c95ee5957c | |||
8a00507431 | |||
a849b065a1 | |||
f6af2ff738 | |||
e9a24f0d75 | |||
853657fa94 | |||
9de6596b04 | |||
acf257bc8f | |||
0a29b9f3c8 | |||
03551be949 | |||
99713d1740 | |||
c3e0afefa5 | |||
c089594c7f | |||
b4ff2da7f5 | |||
ef0a79de21 | |||
b8d807eb31 | |||
830ca4c9c4 | |||
2835681e2a | |||
4dc580bfa2 | |||
abd608ca31 | |||
5a809e5903 | |||
eb44093f09 | |||
7d8d1d9be3 | |||
6456061571 | |||
9c680d459b | |||
1df9bff71c | |||
a451361c0d | |||
88f36531b1 | |||
017c07a1cf | |||
0f744f861b | |||
52320227d2 |
5
.eslintrc.cjs
Normal file → Executable 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
5
.prettierrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
0
.prod/Dockerfile
Normal file → Executable file
@ -1,4 +1,4 @@
|
||||
cd ~/Documents/Web/Tech_Tracker_Web
|
||||
cd ~/Documents/Web/Tech_Tracker_Web || exit
|
||||
git pull
|
||||
pnpm update
|
||||
sudo docker restart techtracker
|
||||
|
56
README.md
Normal file → Executable 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
53
docker/development/Dockerfile
Normal 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
|
17
docker/development/compose.yaml
Normal 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
|
||||
|
54
docker/production/Dockerfile
Normal 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
|
16
docker/production/compose.yaml
Normal 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
@ -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
@ -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=""
|
@ -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;
|
||||
|
66
package.json
Normal file → Executable file
@ -12,44 +12,66 @@
|
||||
"dev": "next dev",
|
||||
"lint": "next lint",
|
||||
"start": "next start",
|
||||
"go": "git pull && 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": {
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@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"
|
||||
|
8156
pnpm-lock.yaml
generated
Normal file → Executable file
0
postcss.config.cjs
Normal file → Executable file
2
prettier.config.js
Normal file → Executable 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
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
1
public/images/authentik_logo.svg
Normal 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
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
63
public/images/exit_fullscreen.svg
Executable 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
@ -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 |
BIN
public/images/gitea_logo.png
Normal file
After Width: | Height: | Size: 16 KiB |
0
public/images/gitea_logo.svg
Normal file → Executable file
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
0
public/images/microsoft_logo.png
Normal file → Executable file
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 260 KiB |
1
public/images/microsoft_logo.svg
Normal 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 |
BIN
public/images/tech_tracker_appicon.png
Executable file
After Width: | Height: | Size: 98 KiB |
0
public/images/tech_tracker_favicon.png
Normal file → Executable file
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
0
public/images/tech_tracker_logo.png
Normal file → Executable file
Before Width: | Height: | Size: 386 KiB After Width: | Height: | Size: 386 KiB |
105
scripts/files_to_clipboard.py
Executable 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()
|
18
scripts/next.config.build.js
Normal 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;
|
49
scripts/next.config.default.js
Normal 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
@ -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
@ -1,2 +1,2 @@
|
||||
import { handlers } from "~/auth"
|
||||
export const { GET, POST } = handlers
|
||||
import { handlers } from '~/auth';
|
||||
export const { GET, POST } = handlers;
|
||||
|
27
src/app/api/get_paginated_history/route.ts
Executable 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 },
|
||||
);
|
||||
}
|
||||
};
|
29
src/app/api/get_technicians/route.ts
Executable 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 },
|
||||
);
|
||||
}
|
||||
};
|
@ -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 });
|
||||
}
|
||||
};
|
@ -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 });
|
||||
}
|
||||
};
|
38
src/app/api/update_status_by_id/route.ts
Executable 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
57
src/app/api/update_status_by_name/route.ts
Executable 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 },
|
||||
);
|
||||
}
|
||||
};
|
@ -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 });
|
||||
}
|
||||
};
|
@ -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 });
|
||||
}
|
||||
};
|
@ -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 });
|
||||
}
|
||||
};
|
36
src/app/layout.tsx
Normal file → Executable file
@ -1,13 +1,15 @@
|
||||
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.",
|
||||
title: 'Tech Tracker',
|
||||
description:
|
||||
'App used by COG IT employees to \
|
||||
update their status throughout the day.',
|
||||
icons: [
|
||||
{
|
||||
rel: 'icon',
|
||||
@ -21,31 +23,29 @@ export const metadata: Metadata = {
|
||||
},
|
||||
{
|
||||
rel: 'apple-touch-icon',
|
||||
url: '/favicon.ico',
|
||||
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
@ -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
@ -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,
|
||||
}),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
@ -1,38 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
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-2 md:pt-10
|
||||
bg-gradient-to-b from-[#111111] to-[#212325]">
|
||||
<div className="w-2/3 pt-4 pb-2 md:pt-8 md:pb-4 m-auto">
|
||||
<TT_Header />
|
||||
</div>
|
||||
< Sign_In />
|
||||
<div className="w-5/6 mx-auto flex flex-col">
|
||||
<h3 className="text-center text-[16px] md:text-lg italic pt-4">
|
||||
You must have a Gulfport Microsoft 365 Account to sign in.
|
||||
</h3>
|
||||
<Link href="https://authjs.dev/getting-started/providers/microsoft-entra-id"
|
||||
className="text-center text-[16px] md:text-lg italic pt-4 pb-4 text-sky-200
|
||||
hover:text-sky-300"
|
||||
>
|
||||
Tech Tracker uses Auth.js and Microsoft Entra ID for Authentication
|
||||
</Link>
|
||||
<Link href="https://git.gibbyb.com/gib/Tech_Tracker_Web"
|
||||
className="text-center text-[16px] md:text-lg px-4 py-2 md:py-2.5 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-row mt-4"
|
||||
>
|
||||
<Image src="/images/gitea_logo.svg" alt="Gitea" width={35} height={35}
|
||||
className="mr-2"
|
||||
/>
|
||||
<h3 className="my-auto">View Source Code</h3>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
@ -1,31 +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-2 pr-0 md:pt-4 md: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-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>
|
||||
);
|
||||
}
|
||||
};
|
15
src/components/auth/client/Sign_In.tsx
Normal 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>
|
||||
);
|
||||
};
|
48
src/components/auth/client/Sign_Out.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
}
|
15
src/components/auth/client/authentik/Sign_In.tsx
Executable 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>
|
||||
);
|
||||
}
|
15
src/components/auth/client/microsoft/Sign_In.tsx
Executable 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>
|
||||
);
|
||||
}
|
41
src/components/auth/server/Sign_Out.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
}
|
32
src/components/auth/server/authentik/Sign_In.tsx
Executable 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;
|
30
src/components/auth/server/microsoft/Sign_In.tsx
Executable 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>
|
||||
);
|
||||
}
|
32
src/components/context/TVModeContext.tsx
Executable 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
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
182
src/components/ui/History_Drawer.tsx
Executable 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;
|
47
src/components/ui/Loading.tsx
Normal file → Executable file
@ -1,30 +1,29 @@
|
||||
"use client"
|
||||
import * as React from "react"
|
||||
import { Progress } from "~/components/ui/progress"
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
import { Progress } from '~/components/ui/shadcn/progress';
|
||||
|
||||
interface Loading_Props {
|
||||
interval_amount: number
|
||||
interval_amount: number;
|
||||
}
|
||||
|
||||
const Loading: React.FC<Loading_Props> = ({interval_amount}) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
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;
|
||||
|
49
src/components/ui/No_Session.tsx
Executable 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>
|
||||
);
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export default function TT_Header() {
|
||||
return (
|
||||
<header className="w-full py-2 pt-6 md:py-5">
|
||||
<div className="flex flex-row items-center text-center sm:justify-center
|
||||
ml-4 sm:ml-0 p-4">
|
||||
<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 font-bold pl-2 md: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
@ -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>
|
||||
);
|
||||
}
|
@ -1,213 +0,0 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSession } from "next-auth/react";
|
||||
import Loading from "~/components/ui/Loading";
|
||||
|
||||
// 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 { data: session, status } = useSession();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [employeeStatus, setStatus] = useState('');
|
||||
const [employeeData, setEmployeeData] = useState(employees);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "loading") {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
|
||||
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) {
|
||||
if (!session) {
|
||||
alert("You must be signed in to update status.");
|
||||
return;
|
||||
} else {
|
||||
const cur_user = employees.find(employee => employee.name === session?.user?.name);
|
||||
if (cur_user) {
|
||||
await fetch('/api/v2/update_status', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${process.env.API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({ employeeIds: [cur_user.id], newStatus: employeeStatus }),
|
||||
});
|
||||
// Optionally refresh data on the client-side after update
|
||||
const updatedEmployees = await fetchEmployees();
|
||||
setEmployeeData(updatedEmployees);
|
||||
setSelectedIds([]);
|
||||
setStatus('');
|
||||
}
|
||||
}
|
||||
} else if (employeeStatus.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: employeeStatus }),
|
||||
});
|
||||
// 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}`;
|
||||
};
|
||||
if (loading) return <Loading interval_amount={3} />;
|
||||
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 md:py-5 border border-[#3e4446]">{employee.name}</td>
|
||||
<td className="s-column px-1 md:py-5 border border-[#3e4446]">{employee.status}</td>
|
||||
<td className="ua-column px-1 md: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={employeeStatus}
|
||||
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
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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}/>;
|
||||
};
|
48
src/components/ui/scroll-area.tsx
Executable 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 };
|
59
src/components/ui/shadcn/button.tsx
Executable 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 };
|
75
src/components/ui/shadcn/calendar.tsx
Executable 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 };
|
31
src/components/ui/shadcn/checkbox.tsx
Executable 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 };
|
81
src/components/ui/shadcn/combobox.tsx
Executable 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>
|
||||
);
|
||||
}
|
170
src/components/ui/shadcn/command.tsx
Executable 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,
|
||||
};
|
41
src/components/ui/shadcn/date-picker.tsx
Executable 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>
|
||||
);
|
||||
}
|
132
src/components/ui/shadcn/dialog.tsx
Executable 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,
|
||||
};
|
118
src/components/ui/shadcn/drawer.tsx
Executable 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,
|
||||
};
|
215
src/components/ui/shadcn/dropdown-menu.tsx
Executable 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
@ -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,
|
||||
};
|
27
src/components/ui/shadcn/input.tsx
Executable 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 };
|
27
src/components/ui/shadcn/label.tsx
Executable 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 };
|
118
src/components/ui/shadcn/pagination.tsx
Executable 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,
|
||||
};
|
36
src/components/ui/shadcn/popover.tsx
Executable 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 };
|
20
src/components/ui/progress.tsx → src/components/ui/shadcn/progress.tsx
Normal file → Executable file
@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { cn } from '~/lib/utils';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
@ -12,17 +12,17 @@ const Progress = React.forwardRef<
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
'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"
|
||||
className='h-full w-full flex-1 bg-primary transition-all'
|
||||
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
45
src/components/ui/shadcn/radio-group.tsx
Executable 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 };
|
48
src/components/ui/shadcn/scroll-area.tsx
Executable 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 };
|
171
src/components/ui/shadcn/select.tsx
Executable 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,
|
||||
};
|
36
src/components/ui/shadcn/switch.tsx
Executable 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 };
|
118
src/components/ui/shadcn/table.tsx
Executable 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,
|
||||
};
|
31
src/components/ui/shadcn/toaster.tsx
Executable 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 };
|
59
src/components/ui/shadcn/toggle-group.tsx
Executable 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 };
|
48
src/components/ui/shadcn/toggle.tsx
Executable 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 };
|
30
src/components/ui/shadcn/tooltip.tsx
Executable 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
@ -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
@ -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
@ -1 +1 @@
|
||||
export { auth as middleware } from "~/auth"
|
||||
export { auth as middleware } from '~/auth';
|
||||
|
12
src/server/db/index.ts
Normal file → Executable 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
@ -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(),
|
||||
});
|
||||
|
171
src/server/functions.ts
Normal file → Executable file
@ -1,115 +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),
|
||||
});
|
||||
};
|
||||
|
||||
// Uncomment this and change updatedAt below if using localhost and you want correct time.
|
||||
// I dont know why it is like this.
|
||||
//const convertToUTC = (date: Date) => {
|
||||
//return new Date(date.setHours(date.getUTCHours())+ 5);
|
||||
//};
|
||||
|
||||
// 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 = convertToUTC(new Date());
|
||||
const updatedAt = new Date(); // Do not change for PROD! It acts different on PROD
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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: {
|
||||
@ -117,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 = 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");
|
||||
}
|
||||
};
|
||||
|
18
src/styles/globals.css
Normal file → Executable file
@ -119,3 +119,21 @@
|
||||
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
@ -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;
|
||||
|