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) => (
Długa pauza
))} {results?.quiet_segments?.map((segment) => (
Mówienie zbyt cicho
))} {results?.loud_segments?.map((segment) => (
Mówienie zbyt głośno
))}
{/*
*/}
); } ================================================ 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 (
Znalezione modyfikacje
Mówienie zbyt głośno
); } ================================================ 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
2
Liczba zdań
); } ================================================ 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) => (
{file.name}
))}
)} {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 (