1831 lines
56 KiB
Plaintext
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))
|
|
}
|
|
|
|
|