Files
NordicFlow/LLM_CONTEXTS/konradkalemba.txt
Kazimierz Ciołek 7ecefb5621 Initialize repo
2026-02-02 13:56:14 +01:00

1831 lines
56 KiB
Plaintext

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 (
<html lang="pl">
<body className={`${inter.variable} antialiased font-sans`}>
{children}
</body>
</html>
);
}
================================================
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 (
<div className="relative overflow-hidden w-full h-full animate-fade-in">
<motion.div
className="h-[64px] relative left-1/2 transition-transform will-change-transform"
style={{
transform: `translateX(-${Math.round(currentTime * SECOND_WIDTH)}px)`,
}}
>
<AudioVisualizer
key={duration}
blob={file}
width={duration * SECOND_WIDTH}
height={64}
barWidth={2}
gap={0}
barColor={"#00000022"}
style={{ position: "absolute", left: 0, top: 0 }}
/>
{results?.long_pauses?.map((pause) => (
<div
key={pause.start_time}
className="absolute top-[50%] -translate-y-[50%] h-2/3 bg-rose-400/20 border border-rose-300 rounded-md px-0.5 hover:bg-rose-200 transition-colors cursor-pointer before:absolute before:inset-0 before:pattern-diagonal-lines before:pattern-rose-400 before:pattern-bg-rose-100 before:pattern-size-8 before:pattern-opacity-10"
style={{
left: `${pause.start_time * SECOND_WIDTH}px`,
width: `${pause.duration * SECOND_WIDTH}px`,
}}
>
<div className="text-xs text-rose-600 font-medium z-20">
Długa pauza
</div>
</div>
))}
{results?.quiet_segments?.map((segment) => (
<div
key={segment.start_time}
className="absolute top-[50%] -translate-y-[50%] h-2/3 bg-rose-400/20 border border-rose-300 rounded-md px-0.5 hover:bg-rose-200 transition-colors cursor-pointer before:absolute before:inset-0 before:pattern-diagonal-lines before:pattern-rose-400 before:pattern-bg-rose-100 before:pattern-size-8 before:pattern-opacity-10"
style={{
left: `${segment.start_time * SECOND_WIDTH}px`,
width: `${segment.duration * SECOND_WIDTH}px`,
}}
>
<div className="text-xs text-rose-600 font-medium z-20">
Mówienie zbyt cicho
</div>
</div>
))}
{results?.loud_segments?.map((segment) => (
<div
key={segment.start_time}
className="absolute top-[50%] -translate-y-[50%] h-2/3 bg-rose-400/20 border border-rose-300 rounded-md px-0.5 hover:bg-rose-200 transition-colors cursor-pointer before:absolute before:inset-0 before:pattern-diagonal-lines before:pattern-rose-400 before:pattern-bg-rose-100 before:pattern-size-8 before:pattern-opacity-10"
style={{
left: `${segment.start_time * SECOND_WIDTH}px`,
width: `${segment.duration * SECOND_WIDTH}px`,
}}
>
<div className="text-xs text-rose-600 font-medium z-20">
Mówienie zbyt głośno
</div>
</div>
))}
</motion.div>
{/* <div className="absolute bottom-0 left-0 h-full flex items-center space-x-2 bg-white/90 backdrop-blur-sm pr-4">
<Select value="volume">
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Wybierz cechę" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="pauses">Pauzy</SelectItem>
<SelectItem value="volume">Głośność</SelectItem>
<SelectItem value="pitch">Ton</SelectItem>
<SelectItem value="speed">Szybkość</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div> */}
</div>
);
}
================================================
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 (
<div className="bg-neutral-100 animate-fade-in rounded-xl h-[64px] gap-1 flex flex-wrap items-center justify-center w-full relative shadow border border-neutral-200 text-center overflow-hidden">
<motion.div
className="relative w-full h-full left-1/2 transition-transform py-2 px-3 will-change-transform"
style={{
transform: `translateX(-${Math.round(currentTime * SECOND_WIDTH)}px)`,
}}
>
{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 = (
<div
className={cn(
"absolute border rounded-md px-0.5 top-1/2 overflow-hidden transition-opacity",
isError
? "bg-rose-100 border-rose-300 hover:bg-rose-200 text-rose-700 before:absolute before:inset-0 before:pattern-diagonal-lines before:pattern-rose-400 before:pattern-bg-rose-100 before:pattern-size-8 before:pattern-opacity-10"
: "bg-neutral-900/10 border-neutral-150"
)}
style={{
transform: `translateX(${
word.start_time * SECOND_WIDTH
}px) translateY(-50%)`,
width: `${(word.end_time - word.start_time) * SECOND_WIDTH}px`,
opacity: currentTime > word.start_time ? 1 : 0.5,
}}
>
{word.word}
</div>
);
if (isError) {
return (
<ErrorPopover
key={index}
time={word.start_time}
currentTime={currentTime}
description={[
isJargon && "Żargon",
isNonPolishLanguage && "Obcy język",
isPassiveVoice && "Strona bierna",
isNonexistentWord && "Nieistniejące słowo",
isRepetition && "Powtórzenie",
]
.filter(Boolean)
.join(", ")}
>
{component}
</ErrorPopover>
);
}
return <Fragment key={index}>{component}</Fragment>;
})}
{results?.topic_changes?.map((topicChange, index) => (
<ErrorPopover
key={index}
time={results.words[topicChange]?.start_time}
currentTime={currentTime}
description={"Zmiana tematu"}
>
<div
key={index}
className="top-[1px] bg-rose-600 w-4 h-4 flex items-center transition-opacity justify-center rounded-full absolute"
style={{
transform: `translateX(${
results.words[topicChange]?.start_time * SECOND_WIDTH
}px)`,
opacity:
currentTime > results.words[topicChange]?.start_time
? 1
: 0.5,
}}
>
<Swap className="w-3 h-3 text-rose-50" weight="bold" />
</div>
</ErrorPopover>
))}
</motion.div>
</div>
);
}
function ErrorPopover({
time,
currentTime,
description,
children,
}: {
time: number;
currentTime: number;
description: string;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<Popover
open={isOpen || (currentTime > time && currentTime < time + 1)}
onOpenChange={setIsOpen}
>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent side="top" className="w-[200px] flex flex-col gap-1">
<p className="text-xs font-medium text-rose-600">Błąd</p>
<p className="text-sm">{description}</p>
<p className="text-xs text-neutral-500 tabular-nums">
{formatTime(time)}
</p>
</PopoverContent>
</Popover>
);
}
================================================
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 (
<div className="grid grid-cols-3 gap-4 animate-fade-in h-[64px] bg-neutral-900/80 items-center justify-between rounded-full shadow border border-neutral-200 px-4 py-2 pb-3 relative overflow-hidden">
<div className="text-accent/80 text-xs font-medium tabular-nums">
{formatTime(time)} / {formatTime(duration)}
</div>
<div className="flex items-center justify-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="rounded-full hover:bg-accent/10 hover:text-accent text-accent/70"
onClick={() => remote.seek(0)}
disabled={time === 0}
>
<ArrowLineLeft weight="bold" className="w-5 h-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Przewiń do początku</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="secondary"
className="rounded-full"
onClick={() => (isPaused ? remote.play() : remote.pause())}
>
{isPaused ? (
<Play weight="bold" className="w-5 h-5" />
) : (
<Pause weight="bold" className="w-5 h-5" />
)}
</Button>
</TooltipTrigger>{" "}
<TooltipContent>
<p>{isPaused ? "Wznów" : "Wstrzymaj"}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="rounded-full hover:bg-accent/10 hover:text-accent text-accent/70"
onClick={() => (isMuted ? remote.unmute() : remote.mute())}
>
{isMuted ? (
<SpeakerSimpleSlash weight="bold" className="w-5 h-5" />
) : (
<SpeakerSimpleHigh weight="bold" className="w-5 h-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isMuted ? "Odcisz" : "Wycisz"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className=""></div>
<TimeSlider />
</div>
);
}
================================================
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 (
<div className="container mx-auto min-h-screen flex flex-col items-center justify-center gap-10 pt-16">
<div className="flex flex-col items-center justify-center gap-3">
<h1 className="text-4xl font-medium">Analizuj wideo</h1>
<h2 className="text-lg text-neutral-600">
Wybierz plik wideo do oceny jakości wypowiedzi
</h2>
</div>
<Dropzone onFileSelect={(file) => setFile(file)} />
</div>
);
}
================================================
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<AbortController>();
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 <FilePicker />;
}
return (
<div className="w-screen grid grid-cols-1 h-screen gap-4 p-6 grid-rows-[1fr_auto_auto_64px] relative">
<div className="flex items-center justify-center relative">
<div className="absolute inset-0 bg-gradient-radial animate-pulse from-blue-500/30 to-transparent blur-2xl"></div>
<VideoPlayer />
</div>
{status === "ready" ? (
<>
<div className="absolute animate-fade-in flex flex-col gap-4 left-6 top-6 z-20 w-[340px]">
<Errors />
<Summary />
</div>
<div className="absolute animate-fade-in flex flex-col gap-4 right-6 top-6 z-20 w-[340px]">
<ReadibilityScore />
</div>
<Captions />
<AudioTimeline />
<ControlsBar />
</>
) : null}
{(status === "uploading" || status === "processing") && (
<div className="flex flex-col gap-4 items-center justify-center">
<div className="text-sm text-neutral-600 font-medium flex items-center gap-2">
<SpinnerGap className="animate-spin w-4 h-4" weight="bold" />{" "}
{status === "uploading" ? (
<>
Wgrywanie pliku...{" "}
<MotionNumber
value={fileProgress}
format={{
notation: "compact",
style: "percent",
}}
/>
</>
) : (
"Przetwarzanie wideo..."
)}
</div>
<Button
variant="outline"
onClick={() => {
abortController.current?.abort();
setStatus("empty");
}}
>
Anuluj
</Button>
</div>
)}
</div>
);
}
================================================
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<MediaPlayerInstance>;
}
export const useAnalysisStore = create<AnalysisState>()((set) => ({
status: "empty",
player: createRef<MediaPlayerInstance>(),
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 (
<Slider.Root
className="group absolute bottom-0 px-3 inline-flex w-full cursor-pointer touch-none select-none items-center outline-none"
value={[value]}
disabled={!canSeek}
step={Number.isFinite(step) ? step : 1}
ref={previewRootRef}
onValueChange={([value]) => {
setValue(value);
remote.seeking((value / 100) * duration);
}}
onValueCommit={([value]) => {
remote.seek((value / 100) * duration);
}}
>
<Slider.Track className="h-[5px] w-full rounded-sm bg-white/30 relative">
<Slider.Range className="bg-blue-400 absolute h-full rounded-sm will-change-[width]" />
</Slider.Track>
{/* <Slider.Thumb
aria-label="Current Time"
className="block h-[15px] w-[15px] bottom-0 rounded-full border border-blue-200 bg-blue-400 outline-none opacity-0 ring-blue-400/40 transition-opacity group-hocus:opacity-100 focus:opacity-100 focus:ring-4 will-change-[left]"
/> */}
<div
className="flex flex-col items-center justify-center absolute opacity-0 data-[visible]:opacity-100 transition-opacity duration-200 will-change-[left] pointer-events-none bg-neutral-900/50 h-6 tabular-nums rounded-full px-2 text-xs backdrop-blur-xl text-white/90 font-medium"
ref={previewRef}
>
{formatTime(previewTime)}
</div>
</Slider.Root>
);
}
================================================
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 (
<MediaPlayer
className="w-[48%] aspect-video relative bg-slate-900 text-white shadow overflow-hidden rounded-lg ring-media-focus data-[focus]:ring-4"
src={{
src: URL.createObjectURL(file),
type: "video/mp4",
}}
playsInline
ref={player}
>
<MediaProvider></MediaProvider>
</MediaPlayer>
);
}
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 (
<Button
variant="ghost"
className="px-1 h-auto py-0.5 text-blue-600 hover:bg-blue-600/10 hover:text-blue-600 rounded-full"
size="sm"
onClick={() => remote.seek(time)}
>
{formatTime(time)}
</Button>
);
}
export function Errors() {
return (
<div className="bg-white/90 backdrop-blur-lg shadow-sm border border-neutral-200 rounded-lg py-2 px-3 flex flex-col gap-1">
<div className="text-accent-foreground/80 text-xs font-medium">
Znalezione modyfikacje
</div>
<div className="flex">
<div className="bg-neutral-200 px-2 py-1 shadow-sm rounded-full border border-neutral-300 text-sm">
Mówienie zbyt głośno <TimeMarker time={28} />
</div>
</div>
</div>
);
}
================================================
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 (
<div className="bg-white/90 backdrop-blur-lg shadow-sm border border-neutral-200 rounded-lg p-2 flex flex-col gap-1">
<div className="text-accent-foreground/80 text-xs font-medium">
Ocena prostości języka
</div>
<div className="text-5xl text-center text-yellow-500 my-2">
{results?.readability_score}
</div>
<div className="text-xs text-center text-neutral-600">
Współczynnik mglistości Gunninga
</div>
<div className="mt-2 pt-2 border-t grid grid-cols-2 border-neutral-200">
<div className="flex flex-col items-center gap-1">
<div className="text-2xl text-center text-blue-500 my-1">
<MotionNumber
value={
results?.words.reduce(
(acc, word) => acc + (currentTime > word.start_time ? 1 : 0),
0
) ?? 0
}
/>
</div>
<div className="text-neutral-600 text-xs">Liczba słów</div>
</div>
<div className="flex flex-col items-center gap-1">
<div className="text-2xl text-center text-blue-500 my-1">2</div>
<div className="text-neutral-600 text-xs">Liczba zdań</div>
</div>
</div>
</div>
);
}
================================================
FILE: frontend/app/(analysis)/panels/summary.tsx
================================================
import { useAnalysisStore } from "../store";
export function Summary() {
const results = useAnalysisStore((state) => state.results);
return (
<div className="bg-white/90 backdrop-blur-lg shadow-sm border border-neutral-200 rounded-lg py-2 px-3 flex flex-col gap-1">
<div className="text-accent-foreground/80 text-xs font-medium">
Podsumowanie
</div>
<div className="text-sm">
{results?.words.map((word) => word.word).join(" ")}
</div>
</div>
);
}
================================================
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 (
<div
{...getRootProps({
className:
"bg-neutral-100 rounded-xl min-h-[200px] flex p-2 flex-col items-center justify-center w-2/3 relative shadow border border-neutral-200",
})}
>
<BorderBeam />
<input {...getInputProps()} />
{acceptedFiles.length > 0 && (
<div className="w-full flex items-center justify-center gap-2">
{acceptedFiles.map((file) => (
<div
key={file.name}
className="bg-neutral-200 rounded-2xl w-1/2 flex items-center shadow-sm relative"
>
<VideoThumbnail file={file} />
<div className="absolute top-2 max-w-[80%] left-1/2 -translate-x-1/2 bg-neutral-900/50 rounded-full px-3 text-xs backdrop-blur-xl text-white/90 font-medium h-5 flex items-center line-clamp-1 justify-center text-ellipsis overflow-hidden">
{file.name}
</div>
</div>
))}
</div>
)}
{acceptedFiles.length === 0 && (
<p className="text-neutral-500 text-sm font-medium">
Przeciągnij i upuść plik wideo, lub kliknij, aby wybrać plik
</p>
)}
</div>
);
}
================================================
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 (
<video
className="w-full aspect-square object-cover rounded-xl border border-neutral-200"
src={URL.createObjectURL(file)}
ref={setTime}
controls={false}
/>
);
}
================================================
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 (
<div
style={
{
"--size": size,
"--duration": duration,
"--anchor": anchor,
"--border-width": borderWidth,
"--color-from": colorFrom,
"--color-to": colorTo,
"--delay": `-${delay}s`,
} as React.CSSProperties
}
className={cn(
"pointer-events-none absolute inset-0 rounded-[inherit] [border:calc(var(--border-width)*1px)_solid_transparent]",
// mask styles
"![mask-clip:padding-box,border-box] ![mask-composite:intersect] [mask:linear-gradient(transparent,transparent),linear-gradient(white,white)]",
// pseudo styles
"after:absolute after:aspect-square after:w-[calc(var(--size)*1px)] after:animate-border-beam after:[animation-delay:var(--delay)] after:[background:linear-gradient(to_left,var(--color-from),var(--color-to),transparent)] after:[offset-anchor:calc(var(--anchor)*1%)_50%] after:[offset-path:rect(0_auto_auto_0_round_calc(var(--size)*1px))]",
className
)}
/>
);
};
================================================
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<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 };
================================================
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<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 }
================================================
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<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-2 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, 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<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretDown className="h-4 w-4 opacity-50" weight="bold" />
</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}
>
<ChevronUpIcon />
</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}
>
<ChevronDownIcon />
</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("px-2 py-1.5 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-2 pr-8 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 right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check weight="bold" 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,
};
================================================
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<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-blue-500 data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
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<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 bg-primary px-3 py-1.5 text-xs text-primary-foreground 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 }
================================================
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))
}