Directory structure:
└── frontend/
├── README.md
├── components.json
├── Dockerfile
├── env.ts
├── next.config.mjs
├── package.json
├── postcss.config.mjs
├── tailwind.config.ts
├── tsconfig.json
├── .eslintrc.json
├── app/
│ ├── globals.css
│ ├── layout.tsx
│ └── (analysis)/
│ ├── audio-timeline.tsx
│ ├── captions.tsx
│ ├── controls-bar.tsx
│ ├── file-picker.tsx
│ ├── page.tsx
│ ├── store.ts
│ ├── time-slider.tsx
│ ├── video-player.tsx
│ └── panels/
│ ├── errors.tsx
│ ├── readibility-score.tsx
│ └── summary.tsx
├── components/
│ ├── dropzone.tsx
│ ├── video-thumbnail.tsx
│ └── ui/
│ ├── border-beam.tsx
│ ├── button.tsx
│ ├── label.tsx
│ ├── popover.tsx
│ ├── select.tsx
│ ├── switch.tsx
│ └── tooltip.tsx
└── lib/
├── config.ts
└── utils.ts
================================================
FILE: frontend/README.md
================================================
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
================================================
FILE: frontend/components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
================================================
FILE: frontend/Dockerfile
================================================
FROM node:18-alpine
WORKDIR /usr/src/app
COPY . .
RUN npm install
ENV NEXT_PUBLIC_API_URL="http://159.69.15.185:8000"
RUN npm run build
COPY . /usr/src/app
EXPOSE 3000
CMD [ "npm", "run", "start" ]
================================================
FILE: frontend/env.ts
================================================
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
},
experimental__runtimeEnv: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
});
================================================
FILE: frontend/next.config.mjs
================================================
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
const jiti = createJiti(fileURLToPath(import.meta.url));
jiti("./env");
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
================================================
FILE: frontend/package.json
================================================
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@t3-oss/env-nextjs": "^0.11.1",
"@vidstack/react": "^1.12.11",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"framer-motion": "^11.9.0",
"lucide-react": "^0.446.0",
"motion-number": "^0.1.7",
"next": "14.2.13",
"react": "^18",
"react-audio-visualize": "^1.2.0",
"react-dom": "^18",
"react-dropzone": "^14.2.3",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8",
"zustand": "^5.0.0-rc.2"
},
"devDependencies": {
"@types/axios": "^0.14.0",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.13",
"jiti": "^2.0.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tailwindcss-bg-patterns": "^0.3.0",
"typescript": "^5"
}
}
================================================
FILE: frontend/postcss.config.mjs
================================================
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
================================================
FILE: frontend/tailwind.config.ts
================================================
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
fontFamily: {
sans: ["var(--font-inter)"],
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
"1": "hsl(var(--chart-1))",
"2": "hsl(var(--chart-2))",
"3": "hsl(var(--chart-3))",
"4": "hsl(var(--chart-4))",
"5": "hsl(var(--chart-5))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
animation: {
"border-beam": "border-beam calc(var(--duration)*1s) infinite linear",
shine: "shine var(--duration) infinite linear",
"fade-in": "fade-in 0.6s ease-out",
},
keyframes: {
"border-beam": {
"100%": {
"offset-distance": "100%",
},
},
shine: {
"0%": {
"background-position": "0% 0%",
},
"50%": {
"background-position": "100% 100%",
},
to: {
"background-position": "0% 0%",
},
},
"fade-in": {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
},
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
},
},
},
plugins: [
require("@vidstack/react/tailwind.cjs"),
require("tailwindcss-animate"),
require("tailwindcss-bg-patterns"),
],
};
export default config;
================================================
FILE: frontend/tsconfig.json
================================================
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
================================================
FILE: frontend/.eslintrc.json
================================================
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
================================================
FILE: frontend/app/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
/* @media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
} */
body {
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
================================================
FILE: frontend/app/layout.tsx
================================================
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "@vidstack/react/player/styles/base.css";
import "./globals.css";
const inter = Inter({
subsets: ["latin-ext"],
variable: "--font-inter",
});
export const metadata: Metadata = {
title: "Analiza wideo",
description: "Analiza wideo",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
{children}
);
}
================================================
FILE: frontend/app/(analysis)/audio-timeline.tsx
================================================
"use client";
import { useAnalysisStore } from "./store";
import { AudioVisualizer } from "react-audio-visualize";
import { useMediaState } from "@vidstack/react";
import { motion } from "framer-motion";
import { SECOND_WIDTH } from "@/lib/config";
export function AudioTimeline() {
const file = useAnalysisStore((state) => state.file);
const player = useAnalysisStore((state) => state.player);
const results = useAnalysisStore((state) => state.results);
const currentTime = useMediaState("currentTime", player);
const duration = useMediaState("duration", player);
if (!file) return null;
return (
{results?.long_pauses?.map((pause) => (
))}
{results?.quiet_segments?.map((segment) => (
))}
{results?.loud_segments?.map((segment) => (
))}
{/*
*/}
);
}
================================================
FILE: frontend/app/(analysis)/captions.tsx
================================================
import { formatTime, useMediaState } from "@vidstack/react";
import { useAnalysisStore } from "./store";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Fragment, useState } from "react";
import { Swap } from "@phosphor-icons/react/dist/ssr";
import { SECOND_WIDTH } from "@/lib/config";
export function Captions() {
const player = useAnalysisStore((state) => state.player);
const results = useAnalysisStore((state) => state.results);
const currentTime = useMediaState("currentTime", player);
return (
{results?.words.map((word, index) => {
const isJargon = results?.jargon?.includes(word.word.toLowerCase());
const isNonPolishLanguage = results?.non_polish_language?.includes(
word.word.toLowerCase()
);
const isPassiveVoice = results?.passive_voice?.includes(
word.word.toLowerCase()
);
const isNonexistentWord = results?.nonexistent_words?.includes(
word.word.toLowerCase()
);
const isRepetition = results?.repetitions?.includes(index);
const isError =
isJargon ||
isNonPolishLanguage ||
isPassiveVoice ||
isNonexistentWord ||
isRepetition;
const component = (
word.start_time ? 1 : 0.5,
}}
>
{word.word}
);
if (isError) {
return (
{component}
);
}
return {component};
})}
{results?.topic_changes?.map((topicChange, index) => (
results.words[topicChange]?.start_time
? 1
: 0.5,
}}
>
))}
);
}
function ErrorPopover({
time,
currentTime,
description,
children,
}: {
time: number;
currentTime: number;
description: string;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
time && currentTime < time + 1)}
onOpenChange={setIsOpen}
>
{children}
Błąd
{description}
{formatTime(time)}
);
}
================================================
FILE: frontend/app/(analysis)/controls-bar.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import {
ArrowLineLeft,
Pause,
Play,
SpeakerSimpleHigh,
SpeakerSimpleSlash,
} from "@phosphor-icons/react/dist/ssr";
import { TimeSlider } from "./time-slider";
import { formatTime, useMediaRemote, useMediaState } from "@vidstack/react";
import { useAnalysisStore } from "./store";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function ControlsBar() {
const player = useAnalysisStore((state) => state.player);
const time = useMediaState("currentTime", player);
const duration = useMediaState("duration", player);
const isPaused = useMediaState("paused", player);
const isMuted = useMediaState("muted", player);
const remote = useMediaRemote(player);
return (
{formatTime(time)} / {formatTime(duration)}
Przewiń do początku
{" "}
{isPaused ? "Wznów" : "Wstrzymaj"}
{isMuted ? "Odcisz" : "Wycisz"}
);
}
================================================
FILE: frontend/app/(analysis)/file-picker.tsx
================================================
"use client";
import { Dropzone } from "@/components/dropzone";
import { useAnalysisStore } from "./store";
export function FilePicker() {
const setFile = useAnalysisStore((state) => state.setFile);
return (
Analizuj wideo
Wybierz plik wideo do oceny jakości wypowiedzi
setFile(file)} />
);
}
================================================
FILE: frontend/app/(analysis)/page.tsx
================================================
"use client";
import { useRef, useState } from "react";
import axios, { AxiosProgressEvent } from "axios";
import { env } from "@/env";
import MotionNumber from "motion-number";
import { SpinnerGap } from "@phosphor-icons/react/dist/ssr";
import { Button } from "@/components/ui/button";
import { AudioTimeline } from "./audio-timeline";
import { ControlsBar } from "./controls-bar";
import { useAnalysisStore } from "./store";
import { VideoPlayer } from "./video-player";
import { Captions } from "./captions";
import { ReadibilityScore } from "./panels/readibility-score";
import { Summary } from "./panels/summary";
import { FilePicker } from "./file-picker";
import { Errors } from "./panels/errors";
export default function Page() {
const status = useAnalysisStore((state) => state.status);
const setStatus = useAnalysisStore((state) => state.setStatus);
const setResults = useAnalysisStore((state) => state.setResults);
const abortController = useRef();
const [fileProgress, setFileProgress] = useState(0);
useAnalysisStore.subscribe(async (state) => {
if (state.status === "before-upload") {
abortController.current = new AbortController();
setStatus("uploading");
const formData = new FormData();
formData.append("file", state.file as Blob);
const request = await axios.post(
`${env.NEXT_PUBLIC_API_URL}/upload`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
const newProgress =
progressEvent.loaded / (progressEvent.total || 1);
setFileProgress(newProgress);
if (newProgress === 1) {
setStatus("processing");
}
},
signal: abortController.current.signal,
}
);
const json = request.data;
setResults(json);
setStatus("ready");
}
});
if (status === "empty") {
return ;
}
return (
{status === "ready" ? (
<>
>
) : null}
{(status === "uploading" || status === "processing") && (
{" "}
{status === "uploading" ? (
<>
Wgrywanie pliku...{" "}
>
) : (
"Przetwarzanie wideo..."
)}
)}
);
}
================================================
FILE: frontend/app/(analysis)/store.ts
================================================
import { MediaPlayerInstance } from "@vidstack/react";
import { createRef, RefObject } from "react";
import { create } from "zustand";
type Status = "empty" | "before-upload" | "uploading" | "processing" | "ready";
interface AnalysisState {
status: Status;
setStatus: (status: Status) => void;
results?: {
long_pauses: {
start_time: number;
end_time: number;
duration: number;
}[];
quiet_segments: {
start_time: number;
end_time: number;
duration: number;
}[];
loud_segments: {
start_time: number;
end_time: number;
duration: number;
}[];
readability_score: number;
words: {
word: string;
start_time: number;
end_time: number;
syllable_count: number;
}[];
repetitions: number[];
topic_changes: number[];
jargon: string[];
nonexistent_words: string[];
non_polish_language: string[];
passive_voice: string[];
};
setResults: (results: AnalysisState["results"]) => void;
file?: File;
setFile: (file: File) => void;
player: RefObject;
}
export const useAnalysisStore = create()((set) => ({
status: "empty",
player: createRef(),
setFile: (file: File) => set({ file, status: "before-upload" }),
setResults: (results: AnalysisState["results"]) => set({ results }),
setStatus: (status: Status) => set({ status }),
}));
================================================
FILE: frontend/app/(analysis)/time-slider.tsx
================================================
"use client";
import { useEffect, useState } from "react";
import * as Slider from "@radix-ui/react-slider";
import {
formatTime,
useMediaRemote,
useMediaState,
useSliderPreview,
} from "@vidstack/react";
import { useAnalysisStore } from "./store";
export function TimeSlider() {
const player = useAnalysisStore((state) => state.player);
const time = useMediaState("currentTime", player);
const canSeek = useMediaState("canSeek", player);
const duration = useMediaState("duration", player);
const seeking = useMediaState("seeking", player);
const remote = useMediaRemote(player);
const step = (1 / duration) * 100;
const [value, setValue] = useState(0);
const { previewRootRef, previewRef, previewValue } = useSliderPreview({
clamp: true,
offset: 6,
orientation: "horizontal",
});
const previewTime = (previewValue / 100) * duration;
// Keep slider value in-sync with playback.
useEffect(() => {
if (seeking) return;
setValue((time / duration) * 100);
}, [time, duration, seeking]);
return (
{
setValue(value);
remote.seeking((value / 100) * duration);
}}
onValueCommit={([value]) => {
remote.seek((value / 100) * duration);
}}
>
{/* */}
{formatTime(previewTime)}
);
}
================================================
FILE: frontend/app/(analysis)/video-player.tsx
================================================
"use client";
import { MediaPlayer, MediaProvider } from "@vidstack/react";
import { useAnalysisStore } from "./store";
import { memo } from "react";
export function VideoPlayerComponent() {
const file = useAnalysisStore((state) => state.file);
const player = useAnalysisStore((state) => state.player);
if (!file) return null;
return (
);
}
export const VideoPlayer = memo(VideoPlayerComponent);
================================================
FILE: frontend/app/(analysis)/panels/errors.tsx
================================================
import { Button } from "@/components/ui/button";
import { useAnalysisStore } from "../store";
import { formatTime, useMediaRemote } from "@vidstack/react";
function TimeMarker({ time }: { time: number }) {
const player = useAnalysisStore((state) => state.player);
const remote = useMediaRemote(player);
return (
);
}
export function Errors() {
return (
);
}
================================================
FILE: frontend/app/(analysis)/panels/readibility-score.tsx
================================================
import { useMediaState } from "@vidstack/react";
import { useAnalysisStore } from "../store";
import MotionNumber from "motion-number";
export function ReadibilityScore() {
const results = useAnalysisStore((state) => state.results);
const player = useAnalysisStore((state) => state.player);
const currentTime = useMediaState("currentTime", player);
return (
Ocena prostości języka
{results?.readability_score}
Współczynnik mglistości Gunninga
acc + (currentTime > word.start_time ? 1 : 0),
0
) ?? 0
}
/>
Liczba słów
);
}
================================================
FILE: frontend/app/(analysis)/panels/summary.tsx
================================================
import { useAnalysisStore } from "../store";
export function Summary() {
const results = useAnalysisStore((state) => state.results);
return (
Podsumowanie
{results?.words.map((word) => word.word).join(" ")}
);
}
================================================
FILE: frontend/components/dropzone.tsx
================================================
"use client";
import { useDropzone } from "react-dropzone";
import { BorderBeam } from "./ui/border-beam";
import { VideoThumbnail } from "./video-thumbnail";
import { useEffect } from "react";
interface DropzoneProps {
onFileSelect: (file: File) => void;
}
export function Dropzone({ onFileSelect }: DropzoneProps) {
const { acceptedFiles, getRootProps, getInputProps } = useDropzone({
multiple: false,
accept: {
"video/*": [".mp4", ".mov", ".avi", ".mkv"],
},
});
useEffect(() => {
if (acceptedFiles.length > 0) {
onFileSelect(acceptedFiles[0]);
}
}, [acceptedFiles, onFileSelect]);
return (
{acceptedFiles.length > 0 && (
{acceptedFiles.map((file) => (
))}
)}
{acceptedFiles.length === 0 && (
Przeciągnij i upuść plik wideo, lub kliknij, aby wybrać plik
)}
);
}
================================================
FILE: frontend/components/video-thumbnail.tsx
================================================
"use client";
import { useCallback } from "react";
export function VideoThumbnail({ file }: { file: File }) {
const setTime = useCallback((node: HTMLVideoElement) => {
if (node) {
try {
// Set time for better thumbnail
node.currentTime = 10;
} catch (e) {
console.error(e);
}
}
}, []);
return (
);
}
================================================
FILE: frontend/components/ui/border-beam.tsx
================================================
import { cn } from "@/lib/utils";
interface BorderBeamProps {
className?: string;
size?: number;
duration?: number;
borderWidth?: number;
anchor?: number;
colorFrom?: string;
colorTo?: string;
delay?: number;
}
export const BorderBeam = ({
className,
size = 200,
duration = 15,
anchor = 90,
borderWidth = 2,
colorFrom = "#389DEF",
colorTo = "#B838EF",
delay = 0,
}: BorderBeamProps) => {
return (
);
};
================================================
FILE: frontend/components/ui/button.tsx
================================================
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 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes,
VariantProps {
asChild?: boolean;
}
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
================================================
FILE: frontend/components/ui/label.tsx
================================================
"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,
React.ComponentPropsWithoutRef &
VariantProps
>(({ className, ...props }, ref) => (
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
================================================
FILE: frontend/components/ui/popover.tsx
================================================
"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 PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
================================================
FILE: frontend/components/ui/select.tsx
================================================
"use client";
import * as React from "react";
import { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@/lib/utils";
import { CaretDown, Check } from "@phosphor-icons/react/dist/ssr";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, children, ...props }, ref) => (
span]:line-clamp-1",
className
)}
{...props}
>
{children}
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, children, position = "popper", ...props }, ref) => (
{children}
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, children, ...props }, ref) => (
{children}
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
================================================
FILE: frontend/components/ui/switch.tsx
================================================
"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,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
================================================
FILE: frontend/components/ui/tooltip.tsx
================================================
"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,
React.ComponentPropsWithoutRef
>(({ className, sideOffset = 4, ...props }, ref) => (
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
================================================
FILE: frontend/lib/config.ts
================================================
export const SECOND_WIDTH = 200;
================================================
FILE: frontend/lib/utils.ts
================================================
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}