diff --git a/app.php b/app.php
new file mode 100644
index 0000000..c4e028d
--- /dev/null
+++ b/app.php
@@ -0,0 +1,642 @@
+ trim((string)($_SERVER['PROJECT_NAME'] ?? '')) ?: $fallbackTitle,
+ 'description' => trim((string)($_SERVER['PROJECT_DESCRIPTION'] ?? '')) ?: $fallbackDescription,
+ 'image' => trim((string)($_SERVER['PROJECT_IMAGE_URL'] ?? '')),
+ ];
+ }
+
+ function base_url(): string
+ {
+ $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
+ $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
+ return $scheme . '://' . $host;
+ }
+
+ function ffmpeg_binary(): ?string
+ {
+ static $path = null;
+ if ($path !== null) {
+ return $path;
+ }
+
+ $output = [];
+ $code = 0;
+ @exec('command -v ffmpeg 2>/dev/null', $output, $code);
+ $resolved = trim((string)($output[0] ?? ''));
+ $path = ($code === 0 && $resolved !== '') ? $resolved : null;
+ return $path;
+ }
+
+ function ffmpeg_is_available(): bool
+ {
+ return ffmpeg_binary() !== null;
+ }
+
+ function ensure_runtime_dirs(): void
+ {
+ foreach ([APP_UPLOAD_DIR, APP_OUTPUT_DIR] as $dir) {
+ if (!is_dir($dir)) {
+ mkdir($dir, 0775, true);
+ }
+ }
+ }
+
+ function ensure_video_jobs_table(): void
+ {
+ static $ready = false;
+ if ($ready) {
+ return;
+ }
+
+ $sql = file_get_contents(APP_MIGRATION_FILE);
+ if ($sql === false) {
+ throw new RuntimeException('Failed to load migration SQL.');
+ }
+
+ db()->exec($sql);
+ ensure_video_job_columns();
+ $ready = true;
+ }
+
+ function ensure_video_job_columns(): void
+ {
+ $columns = [
+ 'tool_key' => "VARCHAR(40) NOT NULL DEFAULT 'webm_mp4'",
+ 'source_format' => 'VARCHAR(32) DEFAULT NULL',
+ 'target_format' => 'VARCHAR(32) DEFAULT NULL',
+ 'preset_key' => 'VARCHAR(40) DEFAULT NULL',
+ 'output_mime' => 'VARCHAR(100) DEFAULT NULL',
+ 'download_name' => 'VARCHAR(255) DEFAULT NULL',
+ ];
+
+ foreach ($columns as $column => $definition) {
+ ensure_column_exists('video_jobs', $column, $definition);
+ }
+ }
+
+ function ensure_column_exists(string $table, string $column, string $definition): void
+ {
+ $stmt = db()->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = :schema_name AND TABLE_NAME = :table_name AND COLUMN_NAME = :column_name');
+ $stmt->bindValue(':schema_name', DB_NAME, PDO::PARAM_STR);
+ $stmt->bindValue(':table_name', $table, PDO::PARAM_STR);
+ $stmt->bindValue(':column_name', $column, PDO::PARAM_STR);
+ $stmt->execute();
+
+ if ((int) $stmt->fetchColumn() > 0) {
+ return;
+ }
+
+ db()->exec(sprintf('ALTER TABLE `%s` ADD COLUMN `%s` %s', $table, $column, $definition));
+ }
+
+ function cleanup_expired_jobs(): void
+ {
+ static $done = false;
+ if ($done) {
+ return;
+ }
+ $done = true;
+
+ ensure_runtime_dirs();
+ ensure_video_jobs_table();
+
+ $stmt = db()->prepare('SELECT id, input_path, output_path FROM video_jobs WHERE created_at < (UTC_TIMESTAMP() - INTERVAL :hours HOUR)');
+ $stmt->bindValue(':hours', APP_RETENTION_HOURS, PDO::PARAM_INT);
+ $stmt->execute();
+ $jobs = $stmt->fetchAll();
+
+ foreach ($jobs as $job) {
+ foreach (['input_path', 'output_path'] as $field) {
+ $path = (string)($job[$field] ?? '');
+ if ($path !== '' && is_file($path)) {
+ @unlink($path);
+ }
+ }
+ }
+
+ $delete = db()->prepare('DELETE FROM video_jobs WHERE created_at < (UTC_TIMESTAMP() - INTERVAL :hours HOUR)');
+ $delete->bindValue(':hours', APP_RETENTION_HOURS, PDO::PARAM_INT);
+ $delete->execute();
+ }
+
+ function app_boot(): void
+ {
+ ensure_runtime_dirs();
+ ensure_video_jobs_table();
+ cleanup_expired_jobs();
+ }
+
+ function format_bytes(?int $bytes): string
+ {
+ if (!$bytes || $bytes < 1) {
+ return '—';
+ }
+ $units = ['B', 'KB', 'MB', 'GB'];
+ $power = min((int) floor(log($bytes, 1024)), count($units) - 1);
+ $value = $bytes / (1024 ** $power);
+ return number_format($value, $power === 0 ? 0 : 1) . ' ' . $units[$power];
+ }
+
+ function format_datetime(?string $value): string
+ {
+ if (!$value) {
+ return '—';
+ }
+ try {
+ return (new DateTimeImmutable($value, new DateTimeZone('UTC')))->format('M j, Y · H:i') . ' UTC';
+ } catch (Throwable $e) {
+ return $value;
+ }
+ }
+
+ function parse_ini_size_to_bytes(string $value): int
+ {
+ $value = trim($value);
+ if ($value === '') {
+ return 0;
+ }
+
+ $number = (float) $value;
+ $unit = strtolower(substr($value, -1));
+
+ return match ($unit) {
+ 'g' => (int) round($number * 1024 * 1024 * 1024),
+ 'm' => (int) round($number * 1024 * 1024),
+ 'k' => (int) round($number * 1024),
+ default => (int) round($number),
+ };
+ }
+
+ function php_upload_limit_bytes(): int
+ {
+ $uploadMax = parse_ini_size_to_bytes((string) ini_get('upload_max_filesize'));
+ $postMax = parse_ini_size_to_bytes((string) ini_get('post_max_size'));
+
+ if ($uploadMax > 0 && $postMax > 0) {
+ return min($uploadMax, $postMax);
+ }
+
+ return max($uploadMax, $postMax);
+ }
+
+ function effective_upload_limit_bytes(): int
+ {
+ $appLimit = APP_MAX_UPLOAD_MB * 1024 * 1024;
+ $phpLimit = php_upload_limit_bytes();
+
+ if ($phpLimit > 0) {
+ return min($appLimit, $phpLimit);
+ }
+
+ return $appLimit;
+ }
+
+ function effective_upload_limit_mb(): int
+ {
+ return (int) ceil(effective_upload_limit_bytes() / (1024 * 1024));
+ }
+
+ function request_exceeds_upload_limit(): bool
+ {
+ $contentLength = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
+ $limit = effective_upload_limit_bytes();
+
+ return $limit > 0 && $contentLength > $limit && empty($_FILES);
+ }
+
+ function h(?string $value): string
+ {
+ return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ }
+
+ function format_label(?string $value): string
+ {
+ $value = strtoupper((string) $value);
+ return $value !== '' ? $value : '—';
+ }
+
+ function file_extension(string $filename): string
+ {
+ return strtolower((string) pathinfo($filename, PATHINFO_EXTENSION));
+ }
+
+ function status_badge_class(string $status): string
+ {
+ return match ($status) {
+ 'completed' => 'success',
+ 'failed' => 'danger',
+ default => 'warning',
+ };
+ }
+
+ function tool_catalog(): array
+ {
+ static $catalog = null;
+ if ($catalog !== null) {
+ return $catalog;
+ }
+
+ $catalog = [
+ 'webm_mp4' => [
+ 'label' => 'WEBM → MP4',
+ 'short_label' => 'WEBM to MP4',
+ 'description' => 'Convert a WEBM upload into a playback-friendly MP4 for decks, CMS uploads, and cross-device sharing.',
+ 'input_extensions' => ['webm'],
+ 'output_extension' => 'mp4',
+ 'output_mime' => 'video/mp4',
+ 'requires_ffmpeg' => true,
+ 'submit_label' => 'Convert to MP4',
+ 'accept_summary' => 'Accepts WEBM video files.',
+ 'presets' => [],
+ ],
+ 'social_mp4' => [
+ 'label' => 'Social export presets',
+ 'short_label' => 'Social MP4',
+ 'description' => 'Resize WEBM, MP4, or MOV clips into publish-ready MP4 exports for landscape, square, and vertical placements.',
+ 'input_extensions' => ['webm', 'mp4', 'mov'],
+ 'output_extension' => 'mp4',
+ 'output_mime' => 'video/mp4',
+ 'requires_ffmpeg' => true,
+ 'submit_label' => 'Create social export',
+ 'accept_summary' => 'Accepts WEBM, MP4, and MOV video files.',
+ 'presets' => [
+ 'landscape_16_9' => [
+ 'label' => 'Landscape 16:9',
+ 'description' => '1920 × 1080 for YouTube, decks, and standard ads.',
+ 'suffix' => 'landscape-16x9',
+ 'filter' => 'scale=w=1920:h=1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1',
+ ],
+ 'square_1_1' => [
+ 'label' => 'Square 1:1',
+ 'description' => '1080 × 1080 for feeds and mixed placements.',
+ 'suffix' => 'square-1x1',
+ 'filter' => 'scale=w=1080:h=1080:force_original_aspect_ratio=decrease,pad=1080:1080:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1',
+ ],
+ 'vertical_9_16' => [
+ 'label' => 'Vertical 9:16',
+ 'description' => '1080 × 1920 for Reels, Shorts, and story placements.',
+ 'suffix' => 'vertical-9x16',
+ 'filter' => 'scale=w=1080:h=1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1',
+ ],
+ ],
+ ],
+ 'subtitle_convert' => [
+ 'label' => 'Subtitle converter',
+ 'short_label' => 'SRT ↔ VTT',
+ 'description' => 'Convert subtitle assets between SRT and WebVTT without leaving the browser workflow.',
+ 'input_extensions' => ['srt', 'vtt'],
+ 'output_extension' => '',
+ 'output_mime' => '',
+ 'requires_ffmpeg' => false,
+ 'submit_label' => 'Convert subtitles',
+ 'accept_summary' => 'Accepts SRT and VTT subtitle files.',
+ 'presets' => [],
+ ],
+ ];
+
+ return $catalog;
+ }
+
+ function tool_option(string $toolKey): array
+ {
+ $catalog = tool_catalog();
+ return $catalog[$toolKey] ?? $catalog['webm_mp4'];
+ }
+
+ function preset_option(string $toolKey, ?string $presetKey): ?array
+ {
+ if ($presetKey === null || $presetKey === '') {
+ return null;
+ }
+
+ $tool = tool_option($toolKey);
+ return $tool['presets'][$presetKey] ?? null;
+ }
+
+ function tool_accept_attribute(string $toolKey): string
+ {
+ $tool = tool_option($toolKey);
+ $extensions = array_map(static fn(string $extension): string => '.' . $extension, $tool['input_extensions']);
+ return implode(',', $extensions);
+ }
+
+ function tool_requires_ffmpeg(string $toolKey): bool
+ {
+ return !empty(tool_option($toolKey)['requires_ffmpeg']);
+ }
+
+ function subtitle_target_options(): array
+ {
+ return ['srt' => 'SRT', 'vtt' => 'VTT'];
+ }
+
+ function build_download_name(string $originalName, string $toolKey, string $targetFormat, ?string $presetKey = null): string
+ {
+ $base = preg_replace('/[^A-Za-z0-9._-]/', '-', (string) pathinfo($originalName, PATHINFO_FILENAME)) ?: 'converted-file';
+ $suffix = '';
+
+ if ($toolKey === 'social_mp4') {
+ $preset = preset_option($toolKey, $presetKey);
+ $suffix = '-' . ($preset['suffix'] ?? 'social-export');
+ } elseif ($toolKey === 'subtitle_convert') {
+ $suffix = '-converted';
+ }
+
+ return $base . $suffix . '.' . strtolower($targetFormat);
+ }
+
+ function fetch_recent_jobs(int $limit = 8): array
+ {
+ app_boot();
+ $stmt = db()->prepare('SELECT id, public_id, original_name, status, input_size, output_size, created_at, completed_at, tool_key, source_format, target_format, preset_key, output_mime, download_name FROM video_jobs ORDER BY id DESC LIMIT :limit');
+ $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
+ $stmt->execute();
+ return $stmt->fetchAll();
+ }
+
+ function find_job(string $publicId): ?array
+ {
+ app_boot();
+ $stmt = db()->prepare('SELECT * FROM video_jobs WHERE public_id = :public_id LIMIT 1');
+ $stmt->bindValue(':public_id', $publicId, PDO::PARAM_STR);
+ $stmt->execute();
+ $job = $stmt->fetch();
+ return $job ?: null;
+ }
+
+ function notify_redirect(string $location, string $type, string $message): void
+ {
+ $qs = http_build_query(['notice' => $message, 'type' => $type]);
+ $separator = str_contains($location, '?') ? '&' : '?';
+ header('Location: ' . $location . $separator . $qs);
+ exit;
+ }
+
+ function create_job_record(
+ string $publicId,
+ string $originalName,
+ string $inputPath,
+ int $inputSize,
+ string $toolKey,
+ string $sourceFormat,
+ string $targetFormat,
+ ?string $presetKey,
+ ?string $outputMime,
+ string $downloadName
+ ): int {
+ $stmt = db()->prepare('INSERT INTO video_jobs (public_id, original_name, input_path, input_size, status, tool_key, source_format, target_format, preset_key, output_mime, download_name) VALUES (:public_id, :original_name, :input_path, :input_size, :status, :tool_key, :source_format, :target_format, :preset_key, :output_mime, :download_name)');
+ $stmt->bindValue(':public_id', $publicId, PDO::PARAM_STR);
+ $stmt->bindValue(':original_name', $originalName, PDO::PARAM_STR);
+ $stmt->bindValue(':input_path', $inputPath, PDO::PARAM_STR);
+ $stmt->bindValue(':input_size', $inputSize, PDO::PARAM_INT);
+ $stmt->bindValue(':status', 'processing', PDO::PARAM_STR);
+ $stmt->bindValue(':tool_key', $toolKey, PDO::PARAM_STR);
+ $stmt->bindValue(':source_format', $sourceFormat, PDO::PARAM_STR);
+ $stmt->bindValue(':target_format', $targetFormat, PDO::PARAM_STR);
+ $stmt->bindValue(':preset_key', $presetKey, $presetKey !== null ? PDO::PARAM_STR : PDO::PARAM_NULL);
+ $stmt->bindValue(':output_mime', $outputMime, $outputMime !== null ? PDO::PARAM_STR : PDO::PARAM_NULL);
+ $stmt->bindValue(':download_name', $downloadName, PDO::PARAM_STR);
+ $stmt->execute();
+ return (int) db()->lastInsertId();
+ }
+
+ function mark_job_completed(int $id, string $outputPath, int $outputSize): void
+ {
+ $stmt = db()->prepare('UPDATE video_jobs SET status = :status, output_path = :output_path, output_size = :output_size, completed_at = UTC_TIMESTAMP(), error_message = NULL WHERE id = :id');
+ $stmt->bindValue(':status', 'completed', PDO::PARAM_STR);
+ $stmt->bindValue(':output_path', $outputPath, PDO::PARAM_STR);
+ $stmt->bindValue(':output_size', $outputSize, PDO::PARAM_INT);
+ $stmt->bindValue(':id', $id, PDO::PARAM_INT);
+ $stmt->execute();
+ }
+
+ function mark_job_failed(int $id, string $error): void
+ {
+ $stmt = db()->prepare('UPDATE video_jobs SET status = :status, error_message = :error_message WHERE id = :id');
+ $stmt->bindValue(':status', 'failed', PDO::PARAM_STR);
+ $stmt->bindValue(':error_message', mb_substr($error, 0, 1500), PDO::PARAM_STR);
+ $stmt->bindValue(':id', $id, PDO::PARAM_INT);
+ $stmt->execute();
+ }
+
+ function convert_media_to_mp4(string $inputPath, string $outputPath, ?string $videoFilter = null): array
+ {
+ $binary = ffmpeg_binary();
+ if (!$binary) {
+ return ['success' => false, 'message' => 'FFmpeg is not installed on this server yet.'];
+ }
+
+ $filterArg = $videoFilter ? ' -vf ' . escapeshellarg($videoFilter) : '';
+ $cmd = sprintf(
+ '%s -y -i %s%s -c:v libx264 -preset veryfast -crf 22 -movflags +faststart -pix_fmt yuv420p -c:a aac -b:a 128k %s 2>&1',
+ escapeshellarg($binary),
+ escapeshellarg($inputPath),
+ $filterArg,
+ escapeshellarg($outputPath)
+ );
+
+ $output = [];
+ $code = 0;
+ exec($cmd, $output, $code);
+ $log = trim(implode(PHP_EOL, $output));
+
+ if ($code !== 0 || !is_file($outputPath)) {
+ return ['success' => false, 'message' => $log !== '' ? $log : 'Conversion failed.'];
+ }
+
+ return ['success' => true, 'message' => 'Conversion completed.'];
+ }
+
+ function normalize_newlines(string $content): string
+ {
+ $content = preg_replace('/^/', '', $content) ?? $content;
+ return str_replace(["
+", "
"], "
+", $content);
+ }
+
+ function convert_srt_content_to_vtt(string $content): string
+ {
+ $content = normalize_newlines($content);
+ $content = preg_replace('/(\d{2}:\d{2}:\d{2}),(\d{3})/', '$1.$2', $content) ?? $content;
+ return "WEBVTT
+
+" . trim($content) . "
+";
+ }
+
+ function convert_vtt_content_to_srt(string $content): string
+ {
+ $content = normalize_newlines($content);
+ $content = preg_replace('/^WEBVTT\s*/', '', $content) ?? $content;
+ $blocks = preg_split('/
+{2,}/', trim($content)) ?: [];
+ $cues = [];
+ $counter = 1;
+
+ foreach ($blocks as $block) {
+ $lines = array_values(array_filter(explode("
+", trim($block)), static fn(string $line): bool => trim($line) !== ''));
+ if ($lines === []) {
+ continue;
+ }
+
+ $headline = strtoupper(trim($lines[0]));
+ if (str_starts_with($headline, 'NOTE') || str_starts_with($headline, 'STYLE') || str_starts_with($headline, 'REGION')) {
+ continue;
+ }
+
+ if (count($lines) > 1 && !str_contains($lines[0], '-->') && str_contains($lines[1], '-->')) {
+ array_shift($lines);
+ }
+
+ $timecode = array_shift($lines);
+ if ($timecode === null || !str_contains($timecode, '-->')) {
+ continue;
+ }
+
+ $timecode = preg_replace('/(\d{2}:\d{2}:\d{2})\.(\d{3})/', '$1,$2', $timecode) ?? $timecode;
+ $cues[] = $counter . "
+" . $timecode . "
+" . implode("
+", $lines);
+ $counter++;
+ }
+
+ return $cues !== [] ? implode("
+
+", $cues) . "
+" : '';
+ }
+
+ function convert_subtitle_file(string $inputPath, string $outputPath, string $sourceFormat, string $targetFormat): array
+ {
+ $content = file_get_contents($inputPath);
+ if ($content === false) {
+ return ['success' => false, 'message' => 'Could not read the uploaded subtitle file.'];
+ }
+
+ $converted = match (strtolower($sourceFormat) . ':' . strtolower($targetFormat)) {
+ 'srt:vtt' => convert_srt_content_to_vtt($content),
+ 'vtt:srt' => convert_vtt_content_to_srt($content),
+ default => null,
+ };
+
+ if ($converted === null || $converted === '') {
+ return ['success' => false, 'message' => 'Unsupported subtitle conversion request.'];
+ }
+
+ if (file_put_contents($outputPath, $converted) === false) {
+ return ['success' => false, 'message' => 'Could not write the converted subtitle file.'];
+ }
+
+ return ['success' => true, 'message' => 'Subtitle conversion completed.'];
+ }
+
+ function job_tool_key(array $job): string
+ {
+ $toolKey = (string)($job['tool_key'] ?? '');
+ return array_key_exists($toolKey, tool_catalog()) ? $toolKey : 'webm_mp4';
+ }
+
+ function job_source_format(array $job): string
+ {
+ $source = strtolower((string)($job['source_format'] ?? ''));
+ if ($source !== '') {
+ return $source;
+ }
+ return file_extension((string)($job['original_name'] ?? 'video.webm')) ?: 'webm';
+ }
+
+ function job_target_format(array $job): string
+ {
+ $target = strtolower((string)($job['target_format'] ?? ''));
+ if ($target !== '') {
+ return $target;
+ }
+ return 'mp4';
+ }
+
+ function job_tool_label(array $job): string
+ {
+ return tool_option(job_tool_key($job))['label'];
+ }
+
+ function job_preset_label(array $job): string
+ {
+ $preset = preset_option(job_tool_key($job), (string)($job['preset_key'] ?? ''));
+ return $preset['label'] ?? '';
+ }
+
+ function job_conversion_summary(array $job): string
+ {
+ $summary = format_label(job_source_format($job)) . ' → ' . format_label(job_target_format($job));
+ $presetLabel = job_preset_label($job);
+ if ($presetLabel !== '') {
+ $summary .= ' · ' . $presetLabel;
+ }
+ return $summary;
+ }
+
+ function job_download_label(array $job): string
+ {
+ return 'Download ' . format_label(job_target_format($job));
+ }
+
+ function job_output_exists(array $job): bool
+ {
+ return !empty($job['output_path']) && is_file((string) $job['output_path']);
+ }
+
+ function job_download_name(array $job): string
+ {
+ $downloadName = trim((string)($job['download_name'] ?? ''));
+ if ($downloadName !== '') {
+ return $downloadName;
+ }
+
+ return build_download_name(
+ (string)($job['original_name'] ?? 'converted-file'),
+ job_tool_key($job),
+ job_target_format($job),
+ (string)($job['preset_key'] ?? '')
+ );
+ }
+
+ function job_output_mime(array $job): string
+ {
+ $mime = trim((string)($job['output_mime'] ?? ''));
+ if ($mime !== '') {
+ return $mime;
+ }
+
+ return match (job_target_format($job)) {
+ 'vtt' => 'text/vtt',
+ 'srt' => 'application/x-subrip',
+ default => 'video/mp4',
+ };
+ }
diff --git a/assets/css/custom.css b/assets/css/custom.css
index 789132e..268ab04 100644
--- a/assets/css/custom.css
+++ b/assets/css/custom.css
@@ -1,403 +1,1056 @@
+:root {
+ --bg: #f5f6f7;
+ --surface: rgba(255, 255, 255, 0.94);
+ --surface-alt: #f8fafc;
+ --surface-strong: #eef2f6;
+ --border: #d9dde3;
+ --border-strong: #bcc4cf;
+ --text: #14171a;
+ --muted: #5c6672;
+ --accent: #111111;
+ --accent-soft: #1f7a4d;
+ --success: #1f7a4d;
+ --warning: #8a5a00;
+ --danger: #b42318;
+ --body-gradient: radial-gradient(circle at top left, rgba(31, 122, 77, 0.10), transparent 28%),
+ radial-gradient(circle at top right, rgba(17, 17, 17, 0.06), transparent 22%),
+ linear-gradient(180deg, #f7f8fa 0%, #f1f4f7 100%);
+ --nav-bg: rgba(245, 246, 247, 0.82);
+ --nav-border: rgba(217, 221, 227, 0.82);
+ --panel-elevated: linear-gradient(180deg, rgba(255, 255, 255, 0.97), rgba(246, 249, 252, 0.97));
+ --soft-card: rgba(255, 255, 255, 0.7);
+ --field-bg: #ffffff;
+ --footer-bg: rgba(245, 246, 247, 0.55);
+ --overlay-bg: rgba(20, 23, 26, 0.28);
+ --toast-bg: #111111;
+ --toast-text: #ffffff;
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --radius-xl: 28px;
+ --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.05);
+ --shadow-md: 0 18px 60px rgba(16, 24, 40, 0.08);
+}
+
+html[data-theme="dark"] {
+ color-scheme: dark;
+ --bg: #0f1418;
+ --surface: rgba(17, 23, 29, 0.92);
+ --surface-alt: #141b22;
+ --surface-strong: #1b2430;
+ --border: rgba(115, 132, 150, 0.32);
+ --border-strong: rgba(129, 149, 170, 0.48);
+ --text: #eef2f6;
+ --muted: #a5b1bd;
+ --accent: #f5f7fa;
+ --accent-soft: #4ecb86;
+ --success: #4ecb86;
+ --warning: #ffca6b;
+ --danger: #ff8c82;
+ --body-gradient: radial-gradient(circle at top left, rgba(78, 203, 134, 0.13), transparent 24%),
+ radial-gradient(circle at top right, rgba(92, 136, 255, 0.12), transparent 24%),
+ linear-gradient(180deg, #0c1115 0%, #111821 100%);
+ --nav-bg: rgba(10, 14, 18, 0.76);
+ --nav-border: rgba(115, 132, 150, 0.28);
+ --panel-elevated: linear-gradient(180deg, rgba(20, 27, 34, 0.98), rgba(12, 17, 22, 0.98));
+ --soft-card: rgba(20, 27, 34, 0.78);
+ --field-bg: #0f151b;
+ --footer-bg: rgba(10, 14, 18, 0.62);
+ --overlay-bg: rgba(5, 7, 10, 0.56);
+ --toast-bg: #edf2f7;
+ --toast-text: #111111;
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.22);
+ --shadow-md: 0 20px 60px rgba(0, 0, 0, 0.35);
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
body {
- background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
- background-size: 400% 400%;
- animation: gradient 15s ease infinite;
- color: #212529;
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
- font-size: 14px;
- margin: 0;
+ position: relative;
+ background: var(--body-gradient);
+ color: var(--text);
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
min-height: 100vh;
}
-.main-wrapper {
- display: flex;
+main,
+footer,
+nav,
+section,
+aside,
+article {
+ position: relative;
+ z-index: 1;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+a:hover {
+ color: inherit;
+}
+
+.page-orb {
+ position: fixed;
+ width: 20rem;
+ height: 20rem;
+ border-radius: 50%;
+ filter: blur(10px);
+ opacity: 0.45;
+ pointer-events: none;
+ z-index: 0;
+}
+
+.orb-left {
+ top: 5rem;
+ left: -7rem;
+ background: radial-gradient(circle, rgba(31, 122, 77, 0.18) 0%, rgba(31, 122, 77, 0) 70%);
+}
+
+.orb-right {
+ top: 11rem;
+ right: -7rem;
+ background: radial-gradient(circle, rgba(17, 17, 17, 0.12) 0%, rgba(17, 17, 17, 0) 72%);
+}
+
+.app-nav {
+ background: var(--nav-bg);
+ border-bottom: 1px solid var(--nav-border);
+ backdrop-filter: blur(18px);
+}
+
+.navbar-brand {
+ color: var(--text);
+ font-weight: 800;
+ letter-spacing: -0.03em;
+}
+
+.brand-mark {
+ width: 2.25rem;
+ height: 2.25rem;
+ display: inline-flex;
align-items: center;
justify-content: center;
- min-height: 100vh;
- width: 100%;
- padding: 20px;
- box-sizing: border-box;
- position: relative;
- z-index: 1;
+ border-radius: 12px;
+ background: linear-gradient(145deg, #111111, #2b3138);
+ color: #ffffff;
+ font-size: 0.84rem;
+ font-weight: 800;
+ box-shadow: 0 10px 24px rgba(17, 17, 17, 0.18);
}
-@keyframes gradient {
- 0% {
- background-position: 0% 50%;
- }
- 50% {
- background-position: 100% 50%;
- }
- 100% {
- background-position: 0% 50%;
- }
+.nav-actions .nav-link {
+ color: var(--muted);
+ transition: color 0.2s ease, transform 0.2s ease;
}
-.chat-container {
- width: 100%;
- max-width: 600px;
- background: rgba(255, 255, 255, 0.85);
- border: 1px solid rgba(255, 255, 255, 0.3);
- border-radius: 20px;
+.nav-actions .nav-link:hover,
+.nav-actions .nav-link:focus-visible {
+ color: var(--text);
+ transform: translateY(-1px);
+}
+
+.nav-link-pill {
+ padding: 0.55rem 0.8rem !important;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: var(--soft-card);
+}
+
+.hero-shell,
+.app-card,
+.hero-panel,
+.info-card {
+ background: var(--surface);
+ border: 1px solid rgba(217, 221, 227, 0.92);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ backdrop-filter: blur(12px);
+}
+
+.hero-shell {
+ padding: 0.35rem;
+ box-shadow: var(--shadow-md);
+}
+
+.hero-panel,
+.app-card,
+.info-card {
+ padding: 1.5rem;
+}
+
+.app-card-highlight {
+ background: var(--panel-elevated);
+ box-shadow: var(--shadow-md);
+}
+
+.hero-panel {
+ min-height: 100%;
display: flex;
flex-direction: column;
- height: 85vh;
- box-shadow: 0 20px 40px rgba(0,0,0,0.2);
- backdrop-filter: blur(15px);
- -webkit-backdrop-filter: blur(15px);
+ justify-content: center;
overflow: hidden;
}
-.chat-header {
- padding: 1.5rem;
- border-bottom: 1px solid rgba(0, 0, 0, 0.05);
- background: rgba(255, 255, 255, 0.5);
- font-weight: 700;
- font-size: 1.1rem;
- display: flex;
- justify-content: space-between;
+.hero-copy-block {
+ max-width: 42rem;
+}
+
+.eyebrow {
+ display: inline-flex;
align-items: center;
+ gap: 0.375rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--muted);
+ margin-bottom: 1rem;
}
-.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 1.25rem;
-}
-
-/* Custom Scrollbar */
-::-webkit-scrollbar {
- width: 6px;
-}
-
-::-webkit-scrollbar-track {
- background: transparent;
-}
-
-::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.3);
- border-radius: 10px;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.5);
-}
-
-.message {
- max-width: 85%;
- padding: 0.85rem 1.1rem;
- border-radius: 16px;
- line-height: 1.5;
- font-size: 0.95rem;
- box-shadow: 0 4px 15px rgba(0,0,0,0.05);
- animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
-}
-
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(20px) scale(0.95); }
- to { opacity: 1; transform: translateY(0) scale(1); }
-}
-
-.message.visitor {
- align-self: flex-end;
- background: linear-gradient(135deg, #212529 0%, #343a40 100%);
- color: #fff;
- border-bottom-right-radius: 4px;
-}
-
-.message.bot {
- align-self: flex-start;
- background: #ffffff;
- color: #212529;
- border-bottom-left-radius: 4px;
-}
-
-.chat-input-area {
- padding: 1.25rem;
- background: rgba(255, 255, 255, 0.5);
- border-top: 1px solid rgba(0, 0, 0, 0.05);
-}
-
-.chat-input-area form {
- display: flex;
- gap: 0.75rem;
-}
-
-.chat-input-area input {
- flex: 1;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- padding: 0.75rem 1rem;
- outline: none;
- background: rgba(255, 255, 255, 0.9);
- transition: all 0.3s ease;
-}
-
-.chat-input-area input:focus {
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
-}
-
-.chat-input-area button {
- background: #212529;
- color: #fff;
- border: none;
- padding: 0.75rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- transition: all 0.3s ease;
-}
-
-.chat-input-area button:hover {
- background: #000;
- transform: translateY(-2px);
- box-shadow: 0 5px 15px rgba(0,0,0,0.2);
-}
-
-/* Background Animations */
-.bg-animations {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- z-index: 0;
- overflow: hidden;
- pointer-events: none;
-}
-
-.blob {
- position: absolute;
- width: 500px;
- height: 500px;
- background: rgba(255, 255, 255, 0.2);
- border-radius: 50%;
- filter: blur(80px);
- animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
-}
-
-.blob-1 {
- top: -10%;
- left: -10%;
- background: rgba(238, 119, 82, 0.4);
-}
-
-.blob-2 {
- bottom: -10%;
- right: -10%;
- background: rgba(35, 166, 213, 0.4);
- animation-delay: -7s;
- width: 600px;
- height: 600px;
-}
-
-.blob-3 {
- top: 40%;
- left: 30%;
- background: rgba(231, 60, 126, 0.3);
- animation-delay: -14s;
- width: 450px;
- height: 450px;
-}
-
-@keyframes move {
- 0% { transform: translate(0, 0) rotate(0deg) scale(1); }
- 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
- 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
- 100% { transform: translate(0, 0) rotate(360deg) scale(1); }
-}
-
-.header-link {
- font-size: 14px;
- color: #fff;
- text-decoration: none;
- background: rgba(0, 0, 0, 0.2);
- padding: 0.5rem 1rem;
- border-radius: 8px;
- transition: all 0.3s ease;
-}
-
-.header-link:hover {
- background: rgba(0, 0, 0, 0.4);
- text-decoration: none;
-}
-
-/* Admin Styles */
-.admin-container {
- max-width: 900px;
- margin: 3rem auto;
- padding: 2.5rem;
- background: rgba(255, 255, 255, 0.85);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border-radius: 24px;
- box-shadow: 0 20px 50px rgba(0,0,0,0.15);
- border: 1px solid rgba(255, 255, 255, 0.4);
- position: relative;
- z-index: 1;
-}
-
-.admin-container h1 {
- margin-top: 0;
- color: #212529;
+h1,
+.detail-title {
+ font-size: clamp(2.25rem, 4vw, 4rem);
+ line-height: 0.98;
+ letter-spacing: -0.055em;
+ margin-bottom: 1rem;
font-weight: 800;
}
-.table {
- width: 100%;
- border-collapse: separate;
- border-spacing: 0 8px;
- margin-top: 1.5rem;
+.hero-copy {
+ color: var(--muted);
+ max-width: 44rem;
+ margin-bottom: 0;
+ font-size: 1.02rem;
+ line-height: 1.7;
}
-.table th {
- background: transparent;
- border: none;
- padding: 1rem;
- color: #6c757d;
- font-weight: 600;
- text-transform: uppercase;
- font-size: 0.75rem;
- letter-spacing: 1px;
-}
-
-.table td {
- background: #fff;
- padding: 1rem;
- border: none;
-}
-
-.table tr td:first-child { border-radius: 12px 0 0 12px; }
-.table tr td:last-child { border-radius: 0 12px 12px 0; }
-
-.form-group {
- margin-bottom: 1.25rem;
-}
-
-.form-group label {
- display: block;
- margin-bottom: 0.5rem;
- font-weight: 600;
- font-size: 0.9rem;
-}
-
-.form-control {
- width: 100%;
- padding: 0.75rem 1rem;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- background: #fff;
- transition: all 0.3s ease;
- box-sizing: border-box;
-}
-
-.form-control:focus {
- outline: none;
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
-}
-
-.header-container {
+.hero-actions {
display: flex;
- justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.hero-points {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.9rem;
+}
+
+.hero-points-tight {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.point-card,
+.stat-card,
+.detail-item,
+.download-panel,
+.flow-card,
+.dropzone-label,
+.status-summary {
+ background: var(--surface-alt);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+}
+
+.point-card,
+.stat-card,
+.detail-item,
+.flow-card,
+.download-panel,
+.status-summary {
+ padding: 1rem;
+}
+
+.point-card-accent {
+ background: linear-gradient(180deg, rgba(31, 122, 77, 0.08), rgba(255, 255, 255, 0.95));
+}
+
+.point-label,
+.stat-label,
+.section-subtitle,
+.form-help,
+.mini-note,
+.stat-note,
+.meta-list dt,
+.detail-item span,
+.point-copy,
+.form-help-label,
+.status-summary-label,
+.footer-copy {
+ color: var(--muted);
+ font-size: 0.92rem;
+}
+
+.point-value,
+.stat-value,
+.detail-item strong,
+.form-help-value,
+.status-summary-title,
+.footer-title {
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.point-copy {
+ margin-top: 0.45rem;
+ line-height: 1.55;
+}
+
+.trust-strip {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.65rem;
+}
+
+.trust-pill {
+ display: inline-flex;
align-items: center;
+ padding: 0.5rem 0.75rem;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.7);
+ border: 1px solid rgba(188, 196, 207, 0.85);
+ font-size: 0.86rem;
+ color: var(--muted);
}
-.header-links {
- display: flex;
+.metrics-grid,
+.workflow-grid,
+.info-strip {
+ display: grid;
gap: 1rem;
}
-.admin-card {
- background: rgba(255, 255, 255, 0.6);
- padding: 2rem;
- border-radius: 20px;
- border: 1px solid rgba(255, 255, 255, 0.5);
- margin-bottom: 2.5rem;
- box-shadow: 0 10px 30px rgba(0,0,0,0.05);
+.metrics-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
}
-.admin-card h3 {
- margin-top: 0;
- margin-bottom: 1.5rem;
+.info-strip {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.workflow-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.workflow-grid-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.info-card p,
+.flow-card p {
+ color: var(--muted);
+ line-height: 1.65;
+}
+
+.stat-value {
+ font-size: 1.9rem;
+ letter-spacing: -0.05em;
+ margin: 0.3rem 0;
+}
+
+.stat-value-sm {
+ font-size: 1.2rem;
+ letter-spacing: -0.03em;
+}
+
+.section-title {
+ font-size: 1.15rem;
+ letter-spacing: -0.03em;
font-weight: 700;
}
-.btn-delete {
- background: #dc3545;
- color: white;
- border: none;
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- cursor: pointer;
+.card-header-row {
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+ align-items: flex-start;
}
-.btn-add {
- background: #212529;
- color: white;
- border: none;
- padding: 0.5rem 1rem;
- border-radius: 4px;
- cursor: pointer;
- margin-top: 1rem;
-}
-
-.btn-save {
- background: #0088cc;
- color: white;
- border: none;
- padding: 0.8rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- width: 100%;
- transition: all 0.3s ease;
-}
-
-.webhook-url {
- font-size: 0.85em;
- color: #555;
- margin-top: 0.5rem;
-}
-
-.history-table-container {
- overflow-x: auto;
- background: rgba(255, 255, 255, 0.4);
- padding: 1rem;
- border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.3);
-}
-
-.history-table {
- width: 100%;
-}
-
-.history-table-time {
- width: 15%;
+.status-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.45rem 0.75rem;
+ border-radius: 999px;
+ font-size: 0.82rem;
+ border: 1px solid var(--border);
+ background: var(--soft-card);
white-space: nowrap;
- font-size: 0.85em;
- color: #555;
+ font-weight: 600;
}
-.history-table-user {
- width: 35%;
- background: rgba(255, 255, 255, 0.3);
- border-radius: 8px;
- padding: 8px;
+.status-chip.ready {
+ color: var(--success);
+ border-color: rgba(31, 122, 77, 0.25);
+ background: rgba(31, 122, 77, 0.06);
}
-.history-table-ai {
- width: 50%;
- background: rgba(255, 255, 255, 0.5);
- border-radius: 8px;
- padding: 8px;
+.status-chip.offline {
+ color: var(--warning);
+ border-color: rgba(138, 90, 0, 0.22);
+ background: rgba(138, 90, 0, 0.06);
}
-.no-messages {
+.form-control,
+.btn,
+.table,
+.alert,
+.badge,
+.toast,
+.selected-file {
+ border-radius: var(--radius-sm) !important;
+}
+
+.dropzone-label {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ min-height: 17rem;
+ padding: 1.5rem;
+ border-style: dashed;
text-align: center;
- color: #777;
-}
\ No newline at end of file
+ cursor: pointer;
+ transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
+}
+
+.dropzone-label:hover,
+.dropzone-label:focus,
+.dropzone-label:focus-within {
+ border-color: var(--accent-soft);
+ transform: translateY(-1px);
+ background: linear-gradient(180deg, rgba(31, 122, 77, 0.05), rgba(255, 255, 255, 0.9));
+ box-shadow: 0 16px 40px rgba(17, 17, 17, 0.08);
+ outline: none;
+}
+
+.dropzone-label.is-dragging,
+.dropzone-label.is-has-file {
+ border-color: var(--accent-soft);
+}
+
+.dropzone-label.is-dragging {
+ transform: translateY(-2px) scale(1.01);
+ background: linear-gradient(180deg, rgba(31, 122, 77, 0.12), rgba(255, 255, 255, 0.94));
+ box-shadow: 0 22px 44px rgba(31, 122, 77, 0.14);
+}
+
+.dropzone-label.is-has-file {
+ background: linear-gradient(180deg, rgba(31, 122, 77, 0.08), rgba(255, 255, 255, 0.94));
+}
+
+.dropzone-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.42rem 0.72rem;
+ border-radius: 999px;
+ border: 1px solid rgba(31, 122, 77, 0.16);
+ background: rgba(31, 122, 77, 0.08);
+ color: var(--text);
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+
+.dropzone-icon {
+ width: 3.4rem;
+ height: 3.4rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 20px;
+ background: linear-gradient(145deg, rgba(17, 17, 17, 0.94), rgba(43, 49, 56, 0.94));
+ color: #ffffff;
+ font-size: 1.4rem;
+ box-shadow: 0 12px 24px rgba(17, 17, 17, 0.14);
+}
+
+.dropzone-copy {
+ display: grid;
+ gap: 0.25rem;
+ max-width: 24rem;
+ line-height: 1.55;
+}
+
+.dropzone-title {
+ font-weight: 700;
+ font-size: 1.05rem;
+}
+
+.dropzone-meta,
+.dropzone-subtitle {
+ color: var(--muted);
+ font-size: 0.92rem;
+}
+
+.dropzone-meta {
+ max-width: 24rem;
+}
+
+.dropzone-label .form-control {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.form-control {
+ border-color: var(--border-strong);
+ padding: 0.85rem 1rem;
+ box-shadow: none !important;
+}
+
+.form-control:focus {
+ border-color: #111111;
+}
+
+.selected-file {
+ border: 1px dashed var(--border-strong);
+ background: var(--field-bg);
+ color: var(--text);
+ padding: 0.95rem 1rem;
+ font-size: 0.94rem;
+}
+
+.selected-file-name {
+ display: block;
+ font-weight: 700;
+}
+
+.selected-file-meta {
+ display: block;
+ color: var(--muted);
+ margin-top: 0.2rem;
+}
+
+.form-help-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.75rem;
+}
+
+.form-help-grid > div {
+ background: var(--surface-alt);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 0.85rem 0.9rem;
+}
+
+.form-help-label {
+ display: block;
+ margin-bottom: 0.15rem;
+}
+
+.btn {
+ padding: 0.82rem 1.05rem;
+ font-weight: 700;
+ border-width: 1px;
+ box-shadow: none !important;
+ transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, color 0.18s ease;
+}
+
+.btn:hover,
+.btn:focus-visible {
+ transform: translateY(-1px);
+}
+
+.btn-dark {
+ background: linear-gradient(135deg, #111111, #2a3138);
+ border-color: #111111;
+}
+
+.btn-soft {
+ background: var(--soft-card);
+ border: 1px solid var(--border-strong);
+ color: var(--text);
+}
+
+.btn-soft:hover,
+.btn-soft:focus-visible {
+ background: var(--field-bg);
+ border-color: var(--accent-soft);
+}
+
+.btn-outline-dark {
+ border-color: var(--border-strong);
+}
+
+.mini-note {
+ border-top: 1px solid var(--border);
+ padding-top: 1rem;
+}
+
+.mini-note-title {
+ color: var(--text);
+ font-weight: 700;
+ margin-bottom: 0.5rem;
+}
+
+.mini-steps li + li {
+ margin-top: 0.35rem;
+}
+
+.app-table thead th {
+ color: var(--muted);
+ font-size: 0.82rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border-bottom-color: var(--border);
+ padding-top: 0;
+}
+
+.app-table td,
+.app-table th {
+ padding: 1rem 0.85rem;
+ border-color: var(--border);
+ vertical-align: middle;
+}
+
+.file-name-cell {
+ max-width: 16rem;
+}
+
+.empty-state {
+ padding: 1.6rem;
+ border: 1px dashed var(--border-strong);
+ background: linear-gradient(180deg, rgba(248, 250, 252, 0.9), rgba(255, 255, 255, 0.96));
+ border-radius: var(--radius-md);
+ text-align: center;
+}
+
+.empty-state h3 {
+ font-size: 1.15rem;
+ margin-bottom: 0.4rem;
+}
+
+.detail-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.85rem;
+}
+
+.download-panel {
+ background: linear-gradient(180deg, rgba(31, 122, 77, 0.07), rgba(255, 255, 255, 0.97));
+}
+
+.download-panel-copy p {
+ color: var(--muted);
+}
+
+.detail-badge {
+ font-size: 0.9rem;
+ padding: 0.55rem 0.75rem;
+}
+
+.meta-list {
+ display: grid;
+ gap: 1rem;
+}
+
+.meta-list dt {
+ margin-bottom: 0.25rem;
+}
+
+.meta-list dd {
+ margin: 0;
+ font-weight: 500;
+ word-break: break-word;
+}
+
+.status-summary {
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 0.55rem;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.95));
+}
+
+.status-summary-title {
+ font-size: 1.35rem;
+ letter-spacing: -0.04em;
+}
+
+.status-summary-completed {
+ border-color: rgba(31, 122, 77, 0.24);
+ background: linear-gradient(180deg, rgba(31, 122, 77, 0.08), rgba(255, 255, 255, 0.96));
+}
+
+.status-summary-failed {
+ border-color: rgba(180, 35, 24, 0.18);
+ background: linear-gradient(180deg, rgba(180, 35, 24, 0.05), rgba(255, 255, 255, 0.96));
+}
+
+.status-summary-processing {
+ border-color: rgba(138, 90, 0, 0.2);
+ background: linear-gradient(180deg, rgba(138, 90, 0, 0.06), rgba(255, 255, 255, 0.96));
+}
+
+.flow-card {
+ min-height: 100%;
+}
+
+.flow-step {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.2rem;
+ height: 2.2rem;
+ border-radius: 999px;
+ background: #111111;
+ color: #ffffff;
+ font-size: 0.86rem;
+ font-weight: 700;
+ margin-bottom: 1rem;
+}
+
+.app-alert {
+ border-color: transparent;
+}
+
+.app-alert-block {
+ border-radius: var(--radius-md);
+}
+
+.loading-overlay {
+ position: fixed;
+ inset: 0;
+ background: var(--overlay-bg);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ z-index: 1080;
+}
+
+.loading-card {
+ background: var(--field-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 1.5rem;
+ width: min(100%, 24rem);
+ text-align: center;
+}
+
+.loading-card h2 {
+ font-size: 1.1rem;
+ margin: 1rem 0 0.5rem;
+ letter-spacing: -0.03em;
+}
+
+.toast {
+ background: var(--toast-bg);
+ color: var(--toast-text);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.app-footer {
+ border-top: 1px solid var(--nav-border);
+ background: var(--footer-bg);
+ backdrop-filter: blur(10px);
+}
+
+.footer-title {
+ font-size: 1rem;
+ margin-bottom: 0.2rem;
+}
+
+.footer-links a {
+ color: var(--muted);
+}
+
+.footer-links a:hover,
+.footer-links a:focus-visible {
+ color: var(--text);
+}
+
+@media (max-width: 1199.98px) {
+ .metrics-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .info-strip,
+ .workflow-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 991.98px) {
+ .hero-points,
+ .hero-points-tight,
+ .detail-grid,
+ .form-help-grid,
+ .workflow-grid-2 {
+ grid-template-columns: 1fr;
+ }
+
+ .page-orb {
+ display: none;
+ }
+}
+
+@media (max-width: 767.98px) {
+ .hero-panel,
+ .app-card,
+ .info-card {
+ padding: 1.125rem;
+ }
+
+ .card-header-row,
+ .hero-actions {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .metrics-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .app-table td,
+ .app-table th {
+ white-space: nowrap;
+ }
+
+ h1,
+ .detail-title {
+ font-size: clamp(2rem, 8vw, 2.8rem);
+ }
+}
+
+
+.theme-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.theme-toggle-icon {
+ font-size: 0.95rem;
+ line-height: 1;
+}
+
+.selected-file-warning {
+ border-color: rgba(180, 35, 24, 0.45);
+ background: linear-gradient(180deg, rgba(180, 35, 24, 0.08), var(--field-bg));
+}
+
+html[data-theme="dark"] .brand-mark,
+html[data-theme="dark"] .dropzone-icon,
+html[data-theme="dark"] .btn-dark,
+html[data-theme="dark"] .flow-step {
+ background: linear-gradient(145deg, #eef2f6, #b8c4d0);
+ color: #111111;
+ border-color: rgba(255, 255, 255, 0.12);
+}
+
+html[data-theme="dark"] .dropzone-label:hover,
+html[data-theme="dark"] .dropzone-label:focus,
+html[data-theme="dark"] .dropzone-label:focus-within {
+ background: linear-gradient(180deg, rgba(78, 203, 134, 0.08), rgba(20, 27, 34, 0.92));
+}
+
+html[data-theme="dark"] .dropzone-label.is-dragging {
+ background: linear-gradient(180deg, rgba(78, 203, 134, 0.15), rgba(20, 27, 34, 0.96));
+ box-shadow: 0 22px 44px rgba(0, 0, 0, 0.34);
+}
+
+html[data-theme="dark"] .dropzone-label.is-has-file {
+ background: linear-gradient(180deg, rgba(78, 203, 134, 0.11), rgba(20, 27, 34, 0.94));
+}
+
+html[data-theme="dark"] .dropzone-badge {
+ background: rgba(78, 203, 134, 0.16);
+ border-color: rgba(78, 203, 134, 0.26);
+}
+
+html[data-theme="dark"] .download-panel {
+ background: linear-gradient(180deg, rgba(78, 203, 134, 0.09), rgba(17, 23, 29, 0.96));
+}
+
+html[data-theme="dark"] .empty-state {
+ background: linear-gradient(180deg, rgba(20, 27, 34, 0.95), rgba(13, 18, 24, 0.98));
+}
+
+html[data-theme="dark"] .status-summary {
+ background: linear-gradient(180deg, rgba(20, 27, 34, 0.98), rgba(12, 17, 22, 0.96));
+}
+
+html[data-theme="dark"] .status-summary-completed {
+ background: linear-gradient(180deg, rgba(78, 203, 134, 0.10), rgba(17, 23, 29, 0.96));
+}
+
+html[data-theme="dark"] .status-summary-failed {
+ background: linear-gradient(180deg, rgba(255, 140, 130, 0.09), rgba(17, 23, 29, 0.96));
+}
+
+html[data-theme="dark"] .status-summary-processing {
+ background: linear-gradient(180deg, rgba(255, 202, 107, 0.09), rgba(17, 23, 29, 0.96));
+}
+
+html[data-theme="dark"] .form-control {
+ background: var(--field-bg);
+ color: var(--text);
+}
+
+html[data-theme="dark"] .form-control::file-selector-button {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text);
+ border: 0;
+}
+
+html[data-theme="dark"] .table {
+ --bs-table-bg: transparent;
+ --bs-table-color: var(--text);
+ --bs-table-border-color: var(--border);
+ --bs-table-striped-bg: rgba(255, 255, 255, 0.02);
+ --bs-table-hover-bg: rgba(255, 255, 255, 0.03);
+}
+
+html[data-theme="dark"] .btn-outline-dark {
+ color: var(--text);
+ border-color: var(--border-strong);
+}
+
+html[data-theme="dark"] .btn-outline-dark:hover,
+html[data-theme="dark"] .btn-outline-dark:focus-visible {
+ background: rgba(255, 255, 255, 0.08);
+ color: var(--text);
+}
+
+html[data-theme="dark"] .toast .btn-close {
+ filter: invert(1);
+}
+
+
+.tool-grid,
+.recent-job-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 1rem;
+}
+
+.tool-card,
+.recent-job-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.tool-card-accent {
+ background: var(--panel-elevated);
+}
+
+.tool-card-header,
+.recent-job-head,
+.card-header-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.tool-card-title {
+ font-size: 1.25rem;
+ line-height: 1.2;
+ letter-spacing: -0.03em;
+ margin: 0;
+ font-weight: 700;
+}
+
+.field-stack {
+ display: grid;
+ gap: 1.25rem;
+}
+
+.option-panel {
+ padding: 1rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-alt);
+}
+
+.compact-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ white-space: nowrap;
+ padding: 0.45rem 0.75rem;
+ border-radius: 999px;
+ font-size: 0.78rem;
+ font-weight: 700;
+ border: 1px solid var(--border);
+ background: var(--surface-alt);
+}
+
+.status-pill-success {
+ color: var(--success);
+}
+
+.status-pill-warning {
+ color: var(--warning);
+}
+
+.status-pill-danger {
+ color: var(--danger);
+}
+
+.empty-state-card {
+ text-align: center;
+ padding: 2rem;
+}
+
+.loading-card {
+ width: min(26rem, calc(100vw - 2rem));
+ padding: 1.5rem;
+ border-radius: var(--radius-lg);
+ background: rgba(15, 20, 24, 0.92);
+ color: #fff;
+ text-align: center;
+ box-shadow: var(--shadow-md);
+}
+
+.loading-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1060;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--overlay-bg);
+ backdrop-filter: blur(8px);
+}
+
+#appToast {
+ background: var(--toast-bg);
+ color: var(--toast-text);
+ box-shadow: var(--shadow-md);
+}
+
+html[data-theme="dark"] #appToast .btn-close {
+ filter: invert(1);
+}
+
+@media (max-width: 767.98px) {
+ .tool-card-header,
+ .recent-job-head,
+ .card-header-row {
+ flex-direction: column;
+ }
+
+ .compact-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/assets/js/main.js b/assets/js/main.js
index d349598..1694258 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -1,39 +1,267 @@
document.addEventListener('DOMContentLoaded', () => {
- const chatForm = document.getElementById('chat-form');
- const chatInput = document.getElementById('chat-input');
- const chatMessages = document.getElementById('chat-messages');
+ const form = document.getElementById('converter-form');
+ const input = document.getElementById('source_file') || document.getElementById('video_file');
+ const selectedFile = document.getElementById('selected-file');
+ const overlay = document.getElementById('loading-overlay');
+ const toastEl = document.getElementById('appToast');
+ const submitButton = document.getElementById('submit-button');
+ const themeToggle = document.getElementById('theme-toggle');
+ const themeLabel = document.getElementById('theme-toggle-label');
+ const themeIcon = document.getElementById('theme-toggle-icon');
+ const toolSelect = document.getElementById('tool_key');
+ const toolDescription = document.getElementById('tool-description');
+ const toolRuntimeNote = document.getElementById('tool-runtime-note');
+ const presetGroup = document.getElementById('preset-group');
+ const subtitleTargetGroup = document.getElementById('subtitle-target-group');
+ const dropzone = document.getElementById('file-dropzone');
+ const fileInputTitle = document.getElementById('file-input-title');
+ const fileInputCopy = document.getElementById('file-input-copy');
+ const fileInputMeta = document.getElementById('file-input-meta');
+ const appState = document.body?.dataset?.appState || 'ready';
+ const uploadLimitMb = Number(document.body?.dataset?.uploadLimitMb || 0);
+ const toolConfig = window.formatShiftTools || {};
- const appendMessage = (text, sender) => {
- const msgDiv = document.createElement('div');
- msgDiv.classList.add('message', sender);
- msgDiv.textContent = text;
- chatMessages.appendChild(msgDiv);
- chatMessages.scrollTop = chatMessages.scrollHeight;
+ const applyTheme = (theme) => {
+ document.documentElement.setAttribute('data-theme', theme);
+ document.documentElement.setAttribute('data-bs-theme', theme);
+
+ if (themeToggle) {
+ themeToggle.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false');
+ }
+
+ if (themeLabel) {
+ themeLabel.textContent = theme === 'dark' ? 'Light mode' : 'Dark mode';
+ }
+
+ if (themeIcon) {
+ themeIcon.textContent = theme === 'dark' ? '☀️' : '🌙';
+ }
};
- chatForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const message = chatInput.value.trim();
- if (!message) return;
+ let currentTheme = 'light';
+ try {
+ const savedTheme = localStorage.getItem('fs-theme');
+ const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ currentTheme = savedTheme || (prefersDark ? 'dark' : 'light');
+ } catch (error) {
+ currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
+ }
+ applyTheme(currentTheme);
- appendMessage(message, 'visitor');
- chatInput.value = '';
+ if (themeToggle) {
+ themeToggle.addEventListener('click', () => {
+ currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
+ applyTheme(currentTheme);
+ try {
+ localStorage.setItem('fs-theme', currentTheme);
+ } catch (error) {
+ // Ignore storage failures and keep the in-memory theme.
+ }
+ });
+ }
- try {
- const response = await fetch('api/chat.php', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ message })
- });
- const data = await response.json();
-
- // Artificial delay for realism
- setTimeout(() => {
- appendMessage(data.reply, 'bot');
- }, 500);
- } catch (error) {
- console.error('Error:', error);
- appendMessage("Sorry, something went wrong. Please try again.", 'bot');
+ const currentTool = () => {
+ if (!toolSelect) {
+ return null;
}
- });
+ return toolConfig[toolSelect.value] || null;
+ };
+
+ const setDropzoneState = (state) => {
+ if (!dropzone) {
+ return;
+ }
+
+ dropzone.classList.toggle('is-dragging', state === 'dragging');
+ dropzone.classList.toggle('is-has-file', state === 'has-file');
+ };
+
+ const assignFiles = (files) => {
+ if (!input || !files || files.length === 0) {
+ return false;
+ }
+
+ if (typeof DataTransfer === 'undefined') {
+ return false;
+ }
+
+ const transfer = new DataTransfer();
+ Array.from(files).slice(0, 1).forEach((file) => transfer.items.add(file));
+ input.files = transfer.files;
+ return true;
+ };
+
+ const renderSelectedFile = () => {
+ if (!input || !selectedFile) {
+ return;
+ }
+
+ const file = input.files && input.files[0] ? input.files[0] : null;
+ if (!file) {
+ selectedFile.classList.add('d-none');
+ selectedFile.classList.remove('selected-file-warning');
+ selectedFile.textContent = '';
+ setDropzoneState('idle');
+ return;
+ }
+
+ const config = currentTool();
+ const sizeMb = file.size / (1024 * 1024);
+ const sizeLabel = sizeMb.toFixed(2);
+ const extension = file.name.includes('.') ? file.name.split('.').pop().toLowerCase() : '';
+ const allowedExtensions = (config?.accept || '').split(',').map((item) => item.replace('.', '').trim()).filter(Boolean);
+ const badExtension = allowedExtensions.length > 0 && extension !== '' && !allowedExtensions.includes(extension);
+ const exceedsLimit = uploadLimitMb > 0 && sizeMb > uploadLimitMb;
+ selectedFile.innerHTML = '';
+
+ const name = document.createElement('span');
+ name.className = 'selected-file-name';
+ name.textContent = file.name;
+
+ const meta = document.createElement('span');
+ meta.className = 'selected-file-meta';
+ if (badExtension) {
+ meta.textContent = `${sizeLabel} MB selected · Not supported for the current converter`;
+ } else if (exceedsLimit) {
+ meta.textContent = `${sizeLabel} MB selected · Above the ${uploadLimitMb} MB limit`;
+ } else {
+ meta.textContent = `${sizeLabel} MB selected · Ready for ${config?.label || 'conversion'}`;
+ }
+
+ selectedFile.appendChild(name);
+ selectedFile.appendChild(meta);
+ selectedFile.classList.toggle('selected-file-warning', badExtension || exceedsLimit);
+ selectedFile.classList.remove('d-none');
+ setDropzoneState('has-file');
+
+ if (fileInputTitle) {
+ fileInputTitle.textContent = `Replace file for ${config?.label || 'this converter'}`;
+ }
+ };
+
+ const syncToolUi = () => {
+ const config = currentTool();
+ if (!config) {
+ renderSelectedFile();
+ return;
+ }
+
+ if (input) {
+ input.setAttribute('accept', config.accept || '');
+ }
+
+ if (toolDescription) {
+ toolDescription.textContent = config.description || '';
+ }
+
+ if (toolRuntimeNote) {
+ if (config.requiresFfmpeg && appState !== 'ready') {
+ toolRuntimeNote.textContent = 'This tool needs FFmpeg, which is currently unavailable on the server.';
+ } else if (config.requiresFfmpeg) {
+ toolRuntimeNote.textContent = 'This tool runs through the server FFmpeg pipeline.';
+ } else {
+ toolRuntimeNote.textContent = 'This tool runs without FFmpeg, so it works even if the video runtime is offline.';
+ }
+ }
+
+ if (presetGroup) {
+ presetGroup.classList.toggle('d-none', !config.presets || Object.keys(config.presets).length === 0);
+ }
+
+ if (subtitleTargetGroup) {
+ subtitleTargetGroup.classList.toggle('d-none', toolSelect?.value !== 'subtitle_convert');
+ }
+
+ if (fileInputTitle && !(input?.files && input.files[0])) {
+ fileInputTitle.textContent = `Drop a file for ${config.label}`;
+ }
+
+ if (fileInputCopy) {
+ fileInputCopy.textContent = config.acceptSummary || 'Choose a supported file to continue.';
+ }
+
+ if (fileInputMeta) {
+ const limitCopy = uploadLimitMb > 0 ? `Up to ${uploadLimitMb} MB` : 'Any file size supported by the server';
+ fileInputMeta.textContent = `or click to browse from your device · ${limitCopy}`;
+ }
+
+ if (submitButton) {
+ submitButton.dataset.defaultLabel = config.submitLabel || 'Convert file';
+ submitButton.textContent = config.submitLabel || 'Convert file';
+ }
+
+ renderSelectedFile();
+ };
+
+ if (toastEl && window.bootstrap) {
+ const toastBody = toastEl.querySelector('.toast-body');
+ if (toastBody) {
+ const config = currentTool();
+ if (appState === 'ready') {
+ toastBody.textContent = config ? `Ready for ${config.label}.` : 'Ready to convert files.';
+ } else {
+ toastBody.textContent = 'Video tools are paused because FFmpeg is unavailable, but subtitle conversion still works.';
+ }
+ }
+ const toast = new bootstrap.Toast(toastEl);
+ toast.show();
+ }
+
+ if (toolSelect) {
+ toolSelect.addEventListener('change', syncToolUi);
+ syncToolUi();
+ }
+
+ if (input && selectedFile) {
+ input.addEventListener('change', renderSelectedFile);
+ }
+
+ if (dropzone && input) {
+ ['dragenter', 'dragover'].forEach((eventName) => {
+ dropzone.addEventListener(eventName, (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setDropzoneState('dragging');
+ });
+ });
+
+ ['dragleave', 'dragend'].forEach((eventName) => {
+ dropzone.addEventListener(eventName, (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setDropzoneState(input.files && input.files[0] ? 'has-file' : 'idle');
+ });
+ });
+
+ dropzone.addEventListener('drop', (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const files = event.dataTransfer?.files;
+ const assigned = assignFiles(files);
+ setDropzoneState(assigned ? 'has-file' : 'idle');
+ if (assigned) {
+ renderSelectedFile();
+ }
+ });
+
+ dropzone.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ input.click();
+ }
+ });
+ }
+
+ if (form && overlay) {
+ form.addEventListener('submit', () => {
+ overlay.classList.remove('d-none');
+ overlay.setAttribute('aria-hidden', 'false');
+
+ if (submitButton) {
+ submitButton.disabled = true;
+ submitButton.textContent = 'Uploading and processing…';
+ }
+ });
+ }
});
diff --git a/db/migrations/20260419_create_video_conversion_jobs.sql b/db/migrations/20260419_create_video_conversion_jobs.sql
new file mode 100644
index 0000000..5ba4f3a
--- /dev/null
+++ b/db/migrations/20260419_create_video_conversion_jobs.sql
@@ -0,0 +1,22 @@
+CREATE TABLE IF NOT EXISTS video_jobs (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ public_id CHAR(36) NOT NULL UNIQUE,
+ original_name VARCHAR(255) NOT NULL,
+ input_path VARCHAR(255) NOT NULL,
+ output_path VARCHAR(255) DEFAULT NULL,
+ status VARCHAR(24) NOT NULL DEFAULT 'processing',
+ error_message TEXT DEFAULT NULL,
+ input_size BIGINT UNSIGNED DEFAULT NULL,
+ output_size BIGINT UNSIGNED DEFAULT NULL,
+ tool_key VARCHAR(40) NOT NULL DEFAULT 'webm_mp4',
+ source_format VARCHAR(32) DEFAULT NULL,
+ target_format VARCHAR(32) DEFAULT NULL,
+ preset_key VARCHAR(40) DEFAULT NULL,
+ output_mime VARCHAR(100) DEFAULT NULL,
+ download_name VARCHAR(255) DEFAULT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ completed_at DATETIME DEFAULT NULL,
+ INDEX idx_video_jobs_created_at (created_at),
+ INDEX idx_video_jobs_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/download.php b/download.php
new file mode 100644
index 0000000..de7c511
--- /dev/null
+++ b/download.php
@@ -0,0 +1,29 @@
+ PHP_VERSION,
+ 'ffmpeg' => ffmpeg_is_available() ? 'available' : 'missing',
+ 'database' => 'pending',
+ 'time' => gmdate('c'),
+];
+$statusCode = 200;
+
+try {
+ app_boot();
+ $checks['database'] = 'connected';
+} catch (Throwable $e) {
+ $checks['database'] = 'error';
+ $checks['error'] = $e->getMessage();
+ $statusCode = 500;
+}
+
+http_response_code($statusCode);
+header('Content-Type: application/json; charset=utf-8');
+echo json_encode($checks, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
diff --git a/index.php b/index.php
index 7205f3d..17b592f 100644
--- a/index.php
+++ b/index.php
@@ -1,150 +1,388 @@
($job['status'] ?? '') === 'completed'));
+$failedCount = count(array_filter($jobs, static fn(array $job): bool => ($job['status'] ?? '') === 'failed'));
+$latestJob = $jobs[0] ?? null;
+$toolConfig = [];
+
+foreach ($tools as $key => $tool) {
+ $toolConfig[$key] = [
+ 'label' => $tool['label'],
+ 'description' => $tool['description'],
+ 'accept' => tool_accept_attribute($key),
+ 'acceptSummary' => $tool['accept_summary'],
+ 'submitLabel' => $tool['submit_label'],
+ 'requiresFfmpeg' => !empty($tool['requires_ffmpeg']),
+ 'presets' => array_map(
+ static fn(array $preset): array => [
+ 'label' => $preset['label'],
+ 'description' => $preset['description'],
+ ],
+ $tool['presets'] ?? []
+ ),
+ ];
+}
?>
-
-
- New Style
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ = h($meta['title']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
-
-
Analyzing your requirements and generating your website…
-
-
Loading…
+
+
+
+
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
+
+
+
+
+
+
+
+
+
+ = h($notice) ?>
+
+
+
+
+
+
+
+
+
Sell more than a single codec swap
+
One dashboard for video exports and subtitle conversions.
+
FormatShift now combines the original WEBM → MP4 flow with social-ready MP4 presets and a lightweight SRT ↔ VTT subtitle utility, so the product feels closer to a creator toolkit than a commodity converter.
+
+
+
+
+
+
+ Tool bundle
+ 3 converters
+ Video compatibility, social presets, and subtitle reformatting.
+
+
+ Upload limit
+ Up to = h((string)$maxUpload) ?> MB
+ Applies to video uploads and subtitle assets.
+
+
+ Retention
+ = h((string)$retentionHours) ?> hours
+ Temporary files auto-clean so the VM stays lean.
+
+
+
+
+
+
+
+
+
Runtime snapshot
+
= $ffmpegReady ? 'Video tools online' : 'Video tools paused' ?>
+
= $ffmpegReady ? 'FFmpeg is available, so the MP4-based tools are ready to run.' : 'FFmpeg is unavailable, so only subtitle conversions will succeed right now.' ?>
+
+
+
+
+ Completed jobs
+ = h((string)$completedCount) ?>
+
+
+ Failed jobs
+ = h((string)$failedCount) ?>
+
+
+ Latest activity
+ = $latestJob ? h(job_tool_label($latestJob)) : '—' ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ What this bundle sells better
+
+
+
Core utility
+ WEBM → MP4 stays as the universal compatibility tool.
+
+
+
Upsell angle
+ Social presets turn a generic conversion app into a publishing workflow for agencies and growth teams.
+
+
+
Add-on utility
+ Subtitle format conversion supports accessibility and multi-channel publishing without external tools.
+
+
+
Storage policy
+ Jobs and files are retained for = h((string)$retentionHours) ?> hours before cleanup.
+
+
+
+
+
Helpful defaults
+
+ Use WEBM → MP4 for simple playback compatibility.
+ Use Social export presets for landscape, square, or vertical delivery.
+ Use Subtitle converter when captions need SRT or VTT output.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your first WEBM, social export, or subtitle conversion will show up here with a dedicated detail page and download action.
+
+
+
+
+
+
+
+
= h(job_tool_label($job)) ?>
+
+
= h((string)$job['original_name']) ?>
+
+
= h(ucfirst((string)$job['status'])) ?>
+
+
+
+ Created
+ = h(format_datetime((string)$job['created_at'])) ?>
+
+
+ Input
+ = h(format_bytes(isset($job['input_size']) ? (int)$job['input_size'] : null)) ?>
+
+
+ Output
+ = h(format_bytes(isset($job['output_size']) ? (int)$job['output_size'] : null)) ?>
+
+
+ Download
+ = job_output_exists($job) ? h(job_download_label($job)) : 'Pending' ?>
+
+
+
+
+
+
+
+
-
- Page updated: = htmlspecialchars($now) ?> (UTC)
-
+
+
+
+
+
Processing your job
+
Large uploads and FFmpeg-based exports can take a little while on this VM.
+
+
+
+
+
+
+
+
diff --git a/job.php b/job.php
new file mode 100644
index 0000000..85e5bc6
--- /dev/null
+++ b/job.php
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+
= h($meta['title']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = h($notice) ?>
+
+
+
+
+
+ Job not found
+ The requested conversion record is missing or has already expired from the retention window.
+ Back to dashboard
+
+
+
+
+
+
+
= h($toolLabel) ?>
+
= h($conversionSummary) ?>
+
Original file: = h((string)$job['original_name']) ?>
+
+
+
+
+
+
+
Current state
+
= h($statusTitle) ?>
+
= h($statusCopy) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Converter
+ = h($toolLabel) ?>
+
+
+ Formats
+ = h($conversionSummary) ?>
+
+
+ Input size
+ = h(format_bytes(isset($job['input_size']) ? (int)$job['input_size'] : null)) ?>
+
+
+ Output size
+ = h(format_bytes(isset($job['output_size']) ? (int)$job['output_size'] : null)) ?>
+
+
+ Started
+ = h(format_datetime((string)$job['created_at'])) ?>
+
+
+ Completed
+ = h(format_datetime((string)($job['completed_at'] ?? ''))) ?>
+
+
+
+
+
+
+
= h(job_download_label($job)) ?>
+
The output file is available now. Downloads remain available until the = h((string)APP_RETENTION_HOURS) ?>-hour retention window expires.
+
+
+
+
+
+
Conversion failed.
+
= nl2br(h((string)($job['error_message'] ?? 'The converter did not finish successfully.'))) ?>
+
+
+
+ This job is still marked as processing. Refresh the page in a few seconds if needed.
+
+
+
+
+
+
+ A
+ Input captured
+ The source file and requested tool settings were stored before processing started, so the result can be tracked even if the run fails.
+
+
+ B
+ Output retained
+ Generated files stay available for = h((string)APP_RETENTION_HOURS) ?> hours, after which cleanup removes the temporary artifacts.
+
+
+
+
+
+
+ Run details
+
+
+
Original file
+ = h((string)$job['original_name']) ?>
+
+
+
Status
+ = h(ucfirst((string)$job['status'])) ?>
+
+
+
Preset
+ = h(job_preset_label($job) !== '' ? job_preset_label($job) : '—') ?>
+
+
+
Download name
+ = h(job_download_name($job)) ?>
+
+
+
+
+
Next move
+
+
+ Download the generated file while it is still within the retention window.
+ Return to the dashboard if you want to run another tool or preset.
+
+ Review the error output above for clues.
+ Try a different source file, output choice, or a smaller upload.
+
+ Refresh the page after a short wait.
+ If it stays stuck, start a fresh run from the dashboard.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/process_conversion.php b/process_conversion.php
new file mode 100644
index 0000000..7591a6f
--- /dev/null
+++ b/process_conversion.php
@@ -0,0 +1,124 @@
+ effective_upload_limit_bytes()) {
+ notify_redirect('/', 'warning', 'That upload is larger than the current ' . effective_upload_limit_mb() . ' MB limit. Please choose a smaller file.');
+}
+
+$finfo = new finfo(FILEINFO_MIME_TYPE);
+$mime = strtolower((string) $finfo->file((string) $file['tmp_name']));
+$allowedMimes = match ($toolKey) {
+ 'webm_mp4' => ['video/webm', 'application/octet-stream'],
+ 'social_mp4' => ['video/webm', 'video/mp4', 'application/mp4', 'video/quicktime', 'application/octet-stream'],
+ 'subtitle_convert' => ['text/plain', 'text/vtt', 'application/octet-stream', 'application/x-subrip'],
+ default => ['application/octet-stream'],
+};
+
+if (!in_array($mime, $allowedMimes, true)) {
+ notify_redirect('/', 'warning', 'The uploaded file does not match the expected file type for this converter.');
+}
+
+$targetFormat = 'mp4';
+$presetKey = null;
+$outputMime = $tool['output_mime'] !== '' ? $tool['output_mime'] : null;
+
+if ($toolKey === 'social_mp4') {
+ $presetKey = trim((string)($_POST['preset_key'] ?? ''));
+ if ($presetKey === '' || preset_option($toolKey, $presetKey) === null) {
+ notify_redirect('/', 'warning', 'Please choose a social export preset.');
+ }
+}
+
+if ($toolKey === 'subtitle_convert') {
+ $requestedTarget = strtolower(trim((string)($_POST['subtitle_target'] ?? '')));
+ $allowedTargets = array_keys(subtitle_target_options());
+ if (!in_array($requestedTarget, $allowedTargets, true)) {
+ notify_redirect('/', 'warning', 'Please choose whether to export to SRT or VTT.');
+ }
+ if ($requestedTarget === $extension) {
+ notify_redirect('/', 'warning', 'Please choose the opposite subtitle format so the file is actually converted.');
+ }
+ $targetFormat = $requestedTarget;
+ $outputMime = $targetFormat === 'vtt' ? 'text/vtt' : 'application/x-subrip';
+}
+
+$publicId = bin2hex(random_bytes(16));
+$inputPath = APP_UPLOAD_DIR . '/' . $publicId . '.' . $extension;
+$outputPath = APP_OUTPUT_DIR . '/' . $publicId . '.' . $targetFormat;
+
+if (!move_uploaded_file((string) $file['tmp_name'], $inputPath)) {
+ notify_redirect('/', 'danger', 'We could not save the uploaded file. Please try again.');
+}
+
+$downloadName = build_download_name($originalName, $toolKey, $targetFormat, $presetKey);
+$jobId = create_job_record($publicId, $originalName, $inputPath, $size, $toolKey, $extension, $targetFormat, $presetKey, $outputMime, $downloadName);
+
+$result = match ($toolKey) {
+ 'webm_mp4' => convert_media_to_mp4($inputPath, $outputPath),
+ 'social_mp4' => convert_media_to_mp4($inputPath, $outputPath, (string) preset_option($toolKey, $presetKey)['filter']),
+ 'subtitle_convert' => convert_subtitle_file($inputPath, $outputPath, $extension, $targetFormat),
+ default => ['success' => false, 'message' => 'Unsupported conversion request.'],
+};
+
+if (!empty($result['success']) && is_file($outputPath)) {
+ mark_job_completed($jobId, $outputPath, (int) filesize($outputPath));
+ notify_redirect('/job.php?id=' . urlencode($publicId), 'success', $tool['label'] . ' completed. Your download is ready.');
+}
+
+mark_job_failed($jobId, (string) ($result['message'] ?? 'Conversion failed.'));
+notify_redirect('/job.php?id=' . urlencode($publicId), 'danger', $tool['label'] . ' failed. Check the job details for more information.');
diff --git a/var/converted/6860a83a8266dd38cb84b94e3c739295.mp4 b/var/converted/6860a83a8266dd38cb84b94e3c739295.mp4
new file mode 100644
index 0000000..5029543
Binary files /dev/null and b/var/converted/6860a83a8266dd38cb84b94e3c739295.mp4 differ
diff --git a/var/converted/e78a40e97113e5e506b51b1719fc5898.mp4 b/var/converted/e78a40e97113e5e506b51b1719fc5898.mp4
new file mode 100644
index 0000000..5029543
Binary files /dev/null and b/var/converted/e78a40e97113e5e506b51b1719fc5898.mp4 differ
diff --git a/var/uploads/6860a83a8266dd38cb84b94e3c739295.webm b/var/uploads/6860a83a8266dd38cb84b94e3c739295.webm
new file mode 100644
index 0000000..6aec412
Binary files /dev/null and b/var/uploads/6860a83a8266dd38cb84b94e3c739295.webm differ
diff --git a/var/uploads/e78a40e97113e5e506b51b1719fc5898.webm b/var/uploads/e78a40e97113e5e506b51b1719fc5898.webm
new file mode 100644
index 0000000..6aec412
Binary files /dev/null and b/var/uploads/e78a40e97113e5e506b51b1719fc5898.webm differ