commit 703f50a09d8d5b16dd31f669929c7e99b5fa4c11 Author: maher Date: Wed Oct 29 11:42:25 2025 +0100 first commit diff --git a/.editorconfig b/.editorconfig new file mode 100755 index 0000000..e1e1a99 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true +quote_type = single +max_line_length = 80 + +[*.md] +max_line_length = off +trim_trailing_whitespace = false + +[*.php] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..cce4dfa --- /dev/null +++ b/.env.example @@ -0,0 +1,59 @@ +APP_NAME=MTDb +APP_ENV=local +APP_KEY=base64:NE5wdzVEQm9CeUVMTGRtczNEZXRuVjFaeVQ0QmJTQUc= +APP_DEBUG=true +APP_LOG_LEVEL=debug +APP_URL=http://localhost +APP_TIMEZONE=UTC +APP_LOCALE=english +APP_VERSION=4.0.4 +DISABLE_UPDATE_AUTH=true +BILLING_ENABLED=true +INSTALLED=false +LOCAL_SEARCH_MODE=fulltext +ENABLE_CONTACT_PAGE=true +NOTIFICATIONS_ENABLED=true +SCOUT_DRIVER=mysql +SCOUT_MYSQL_MODE=extended +SCOUT_PREFIX=mtdb_ +API_INTEGRATED=true +CLOCKWORK_ENABLED=false +PULSE_ENABLED=false + +DB_CONNECTION=mysql +DB_HOST=172.28.2.1 +DB_PORT=3306 +DB_DATABASE=mtdb +DB_USERNAME=root +DB_PASSWORD=root +DB_STRICT_MODE=false +DB_PREFIX= + +BROADCAST_DRIVER=log +CACHE_DRIVER=file +SESSION_DRIVER=file +QUEUE_DRIVER=sync + +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_DRIVER=smtp +MAIL_HOST=smtp.mailtrap.io +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null + +PUSHER_APP_ID= +PUSHER_APP_KEY= + +SENTRY_DSN=null + +RATING_COLUMN=tmdb_vote_average + +STATIC_FILE_DELIVERY=null +PUBLIC_DISK_DRIVER=local + +PAYPAL_PRODUCT_ID=b808bbf3-7372-4e57-b6fd-7672a546bd61 +PAYPAL_PRODUCT_NAME="MTDb Subscription" diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100755 index 0000000..bbdaebf --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,64 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./tsconfig.json", + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 13, + "sourceType": "module" + }, + "plugins": ["react", "react-hooks", "@typescript-eslint"], + "ignorePatterns": ["*.config.*", "*.d.ts"], + "rules": { + "react/destructuring-assignment": "off", + "@typescript-eslint/naming-convention": "off", + "react-hooks/rules-of-hooks": "error", + "react/no-array-index-key": "off", + "react/jsx-filename-extension": [ + 1, + { + "extensions": [".tsx"] + } + ], + "import/prefer-default-export": "off", + "prefer-destructuring": "off", + "radix": ["error", "as-needed"], + "prefer-const": [ + "error", + { + "destructuring": "all" + } + ], + "no-use-before-define": "off", + "no-plusplus": "off", + "arrow-body-style": "off", + "@typescript-eslint/no-use-before-define": "off", + "@typescript-eslint/lines-between-class-members": "off", + "no-unused-expressions": "off", + "@typescript-eslint/no-unused-expressions": "error", + "no-param-reassign": "off", + "class-methods-use-this": "off", + "consistent-return": "off", + "default-case": "off", + "no-return-assign": ["error", "except-parens"], + "react/react-in-jsx-scope": "off", + "react/require-default-props": "off", + "react/prop-types": "off", + "react/jsx-props-no-spreading": "off", + "react/jsx-fragments": "off", + "react/function-component-definition": "off", + "import/no-extraneous-dependencies": "off", + "react/display-name": "off", + "react/no-unstable-nested-components": ["error", {"allowAsProps": true}] + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100755 index 0000000..967315d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto +*.css linguist-vendored +*.scss linguist-vendored +*.js linguist-vendored +CHANGELOG.md export-ignore diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100755 index 0000000..2205e66 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,33 @@ +name: Build +on: [push] +jobs: + run: + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET }} + steps: + - uses: actions/checkout@v3 + with: + submodules: 'true' + token: ${{ secrets.GHUB_TOKEN }} + + - name: Install dependencies + run: npm ci + + - name: Build client + run: npm run build + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_KEY }} + aws-secret-access-key: ${{ secrets.AWS_SECRET }} + aws-region: us-west-1 + + - name: Deploy build assets to S3 bucket + run: aws s3 sync ./public/build/ s3://vebto-assets/mtdb/build --delete + + #- name: Deploy vendor assets to S3 bucket + #run: aws s3 sync ./public/vendor/ s3://vebto-assets/mtdb/vendor --delete + diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..3acb8f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +vendor +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +Thumbs.db +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# misc +/storage/tntsearch/*.index +/storage/framework/maintenance.php +.htaccess +public/.htaccess +public/favicon +public/favicon.ico +install_files +/public/hot +/storage/*.key +Homestead.json +Homestead.yaml +.env +data.ms +/test-results/ +/playwright-report/ +/playwright/.cache/ +storageState.json +# translation files (include default "en"), will be created during installation +resources/lang/*.json + +# Sentry Auth Token +.env.sentry-build-plugin diff --git a/.gitmodules b/.gitmodules new file mode 100755 index 0000000..ae1d8ab --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "common"] + path = common + url = git@github.com:RamunasO/common-new.git diff --git a/.prettierignore b/.prettierignore new file mode 100755 index 0000000..35db0db --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +**/routes/*.php +*.min.css diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100755 index 0000000..5ccaf77 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,19 @@ +arrowParens: avoid +bracketSpacing: false +phpVersion: "8.0" +singleQuote: true + +plugins: + - "@prettier/plugin-php" + - "prettier-plugin-tailwindcss" + - "prettier-plugin-blade" + +overrides: + - files: "*.html" + options: + printWidth: 100 + tabWidth: 2 + singleQuote: false + - files: "*.php" + options: + tabWidth: 4 diff --git a/app/Actions/AppValueLists.php b/app/Actions/AppValueLists.php new file mode 100755 index 0000000..c57dc1a --- /dev/null +++ b/app/Actions/AppValueLists.php @@ -0,0 +1,193 @@ +fs->get(base_path('resources/lists/tmdb-languages.json')), + true, + ); + + $usedLanguages = Title::withoutGlobalScope('adult') + ->select('language') + ->groupBy('language') + ->select([DB::raw('count(*) as total'), 'language']) + ->pluck('total', 'language') + ->toArray(); + + return collect($allLanguages) + ->filter(fn($language) => isset($usedLanguages[$language['code']])) + ->map( + fn($language) => [ + 'value' => $language['code'], + 'name' => $language['name'], + 'total' => $usedLanguages[$language['code']] ?? 0, + ], + ) + ->sortByDesc('total') + ->values(); + } + + public function productionCountries($params = []): Collection + { + return ProductionCountry::get(['id', 'name', 'display_name'])->map( + fn($country) => [ + 'value' => $country->id, + 'name' => $country->display_name, + ], + ); + } + + public function genres($params = []): Collection + { + if (Arr::get($params, 'type') === 'tmdb') { + $genres = json_decode( + $this->fs->get(resource_path('lists/tmdb-genres.json')), + true, + ); + return collect($genres)->map( + fn($genre) => [ + 'value' => $genre['id'], + 'name' => $genre['name'], + ], + ); + } else { + return Genre::get(['id', 'name', 'display_name'])->map( + fn($genre) => [ + 'value' => $genre->id, + 'name' => $genre->display_name, + ], + ); + } + } + + public function languages($params = []) + { + if (Arr::get($params, 'type') === 'tmdb') { + return collect( + json_decode( + $this->fs->get(resource_path('lists/tmdb-languages.json')), + true, + ), + ); + } else { + return parent::languages(); + } + } + + public function countries($params = []) + { + if (Arr::get($params, 'type') === 'tmdb') { + return collect( + json_decode( + $this->fs->get(resource_path('lists/tmdb-countries.json')), + true, + ), + ); + } else { + return parent::countries(); + } + } + + public function keywords($params = []) + { + if (Arr::get($params, 'type') === 'tmdb') { + $allKeywords = collect( + json_decode( + $this->fs->get(resource_path('lists/tmdb-keywords.json')), + true, + ), + ); + + if (isset($params['searchQuery'])) { + $allKeywords = $allKeywords + ->filter( + fn($keyword) => str_contains( + strtolower($keyword['name']), + strtolower($params['searchQuery']), + ), + ) + ->values(); + } + + $keywords = $allKeywords->slice(0, 50)->map( + fn($keyword) => [ + 'value' => $keyword['id'], + 'name' => $keyword['name'], + ], + ); + + if ($selectedValue = Arr::get($params, 'selectedValue')) { + $selectedKeyword = $allKeywords->firstWhere( + 'id', + $selectedValue, + ); + if ($selectedKeyword) { + $keywords->prepend([ + 'value' => $selectedKeyword['id'], + 'name' => $selectedKeyword['name'], + ]); + } + } + } else { + $query = isset($params['searchQuery']) + ? Keyword::search($params['searchQuery']) + : Keyword::query(); + + $keywords = $query + ->take(50) + ->get() + ->map( + fn($keyword) => [ + 'value' => $keyword->id, + 'name' => $keyword->display_name, + ], + ); + + if ($selectedValue = Arr::get($params, 'selectedValue')) { + $selectedKeyword = Keyword::find($selectedValue); + if ($selectedKeyword) { + $keywords->prepend([ + 'value' => $selectedKeyword->id, + 'name' => $selectedKeyword->display_name, + ]); + } + } + } + + return $keywords; + } + + public function titleFilterAgeRatings($params = []): Collection + { + return Title::withoutGlobalScope('adult') + ->select('certification') + ->groupBy('certification') + ->select([DB::raw('count(*) as total'), 'certification']) + ->orderBy('total', 'desc') + ->pluck('certification') + ->filter( + fn($certification) => !!$certification && + $certification !== '-', + ) + ->map( + fn($certification) => [ + 'value' => $certification, + 'name' => strtoupper($certification), + ], + ) + ->values(); + } +} diff --git a/app/Actions/Channels/FetchContentFromLocalDatabase.php b/app/Actions/Channels/FetchContentFromLocalDatabase.php new file mode 100755 index 0000000..35edd97 --- /dev/null +++ b/app/Actions/Channels/FetchContentFromLocalDatabase.php @@ -0,0 +1,138 @@ +limit(20) + ->get(); + } elseif ($method === 'mostPopular') { + return $this->getTitleBuilder($filters) + ->orderBy('popularity', 'desc') + ->limit(20) + ->where('is_series', $isSeries) + ->get(); + } elseif ($method === 'topRated') { + return $this->getTopRatedTitles( + $this->getTitleBuilder($filters), + $isSeries, + ); + } elseif ($method === 'upcoming') { + return $this->getMoviesReleasingBetween( + $this->getTitleBuilder($filters), + Carbon::now(), + Carbon::now()->addWeek(), + ); + } elseif ($method === 'nowPlaying') { + return $this->getMoviesReleasingBetween( + $this->getTitleBuilder($filters), + Carbon::now(), + Carbon::now()->subWeek(), + ); + } elseif ($method === 'onTheAir') { + $this->getSeriesAiringBetween( + $this->getTitleBuilder($filters), + Carbon::now(), + Carbon::now()->addWeek(), + ); + } elseif ($method === 'airingToday') { + return $this->getSeriesAiringBetween( + $this->getTitleBuilder($filters), + Carbon::now(), + Carbon::now()->addDay(), + ); + } elseif ($method === 'latestVideos') { + return $this->getTitleBuilder($filters) + ->join('videos', 'titles.id', '=', 'videos.title_id') + ->where('videos.origin', 'local') + ->where('approved', true) + ->orderBy('videos.created_at', 'desc') + ->select('titles.*') + ->distinct() + ->limit(20) + ->get(); + } elseif ($method === 'lastAdded') { + return $this->getTitleBuilder($filters) + ::where('is_series', $isSeries) + ->orderBy('created_at', 'desc') + ->limit(20) + ->get(); + } + } + + private function getTopRatedTitles(mixed $builder, $isSeries): Collection + { + $ratingCol = config('common.site.rating_column'); + + $query = $builder->where('is_series', $isSeries); + + if (Str::contains($ratingCol, 'tmdb_vote_average')) { + $query->orderBy(DB::raw('tmdb_vote_count > 100'), 'desc'); + } + + return $query + ->orderBy($ratingCol, 'desc') + ->limit(20) + ->get(); + } + + private function getMoviesReleasingBetween( + mixed $builder, + $from, + $to, + ): Collection { + return $builder + ->whereBetween('release_date', [$from, $to]) + ->orderBy('popularity', 'desc') + ->limit(20) + ->where('is_series', false) + ->get(['id', 'name']); + } + + private function getSeriesAiringBetween( + mixed $builder, + $from, + $to, + ): Collection { + $titleIds = Episode::whereBetween('release_date', [$from, $to]) + ->limit(300) + ->get(['title_id']) + ->pluck('title_id') + ->unique() + ->slice(0, 20); + + return $builder->whereIn('id', $titleIds)->get(['id', 'name']); + } + + private function getTitleBuilder(?array $filters = []): mixed + { + if (isset($filters['keyword'])) { + return Keyword::findOrFail($filters['keyword'])->titles(); + } elseif (isset($filters['genre'])) { + return Genre::findOrFail($filters['genre'])->titles(); + } elseif (isset($filters['country'])) { + return ProductionCountry::findOrFail($filters['country'])->titles(); + } else { + return Title::query(); + } + } +} diff --git a/app/Actions/Channels/FetchContentFromTmdb.php b/app/Actions/Channels/FetchContentFromTmdb.php new file mode 100755 index 0000000..7e0c7f1 --- /dev/null +++ b/app/Actions/Channels/FetchContentFromTmdb.php @@ -0,0 +1,160 @@ +importPeople($method); + } else { + return $this->ImportTitles($method, $modelType, $filters); + } + } + + protected function importPeople(string $method) + { + if ($method === 'trendingPeople') { + $people = app(TmdbApi::class)->trendingPeople(); + return $people + ->map( + fn($personData) => Person::firstOrCreateFromEncodedTmdbId( + $personData['id'], + )->maybeUpdateFromExternal([ + 'forceAutomation' => true, + ]), + ) + ->filter(); + } + } + + protected function ImportTitles( + string $method, + string $modelType, + ?array $extraFilters = [], + ) { + $filters = $this->buildFilters($method, $modelType); + + if (count($extraFilters)) { + if (isset($extraFilters['keyword'])) { + $filters['with_keywords'] = $extraFilters['keyword']; + } + if (isset($extraFilters['genre'])) { + $filters['with_genres'] = $extraFilters['genre']; + } + if (isset($extraFilters['country'])) { + $filters['with_origin_country'] = $extraFilters['country']; + } + if (isset($extraFilters['language'])) { + $filters['with_original_language'] = $extraFilters['language']; + } + } + + // fetch from tmdb + $titles = app(TmdbApi::class)->browse(1, $modelType, $filters)[ + 'results' + ]; + + // store in local db + return $titles + ->map(function ($titleData) { + return Title::firstOrCreateFromEncodedTmdbId( + $titleData['id'], + )->maybeUpdateFromExternal([ + 'forceAutomation' => true, + //'updateLast3Seasons' => true, + ]); + }) + ->filter(); + } + + protected function buildFilters(string $method, string $modelType): array + { + $type = "{$modelType}.{$method}"; + switch ($type) { + case 'movie.mostPopular': + $from = Carbon::now() + ->subMonths(6) + ->format('Y-m-d'); + return [ + 'sort_by' => 'popularity.desc', + 'primary_release_date.gte' => $from, + ]; + case 'movie.topRated': + case 'series.topRated': + return [ + 'sort_by' => 'vote_average.desc', + 'vote_count.gte' => 600, + ]; + case 'movie.upcoming': + $from = Carbon::now() + ->addDay() + ->format('Y-m-d'); + $to = Carbon::now() + ->addMonth() + ->format('Y-m-d'); + return [ + 'sort_by' => 'popularity.desc', + 'with_release_type' => '2|3', + 'primary_release_date.gte' => $from, + 'primary_release_date.lte' => $to, + ]; + case 'movie.nowPlaying': + $from = Carbon::now() + ->subMonths(2) + ->format('Y-m-d'); + $to = Carbon::now() + ->subDays(2) + ->format('Y-m-d'); + return [ + 'sort_by' => 'popularity.desc', + 'with_release_type' => '2|3', + 'primary_release_date.gte' => $from, + 'primary_release_date.lte' => $to, + ]; + case 'series.mostPopular': + return [ + 'sort_by' => 'popularity.desc', + ]; + case 'series.airingThisWeek': + $from = Carbon::now() + ->startOfDay() + ->format('Y-m-d'); + $to = Carbon::now() + ->startOfDay() + ->addDays(6) + ->format('Y-m-d'); + return [ + 'sort_by' => 'popularity.desc', + 'air_date.gte' => $from, + 'air_date.lte' => $to, + ]; + case 'series.airingToday': + $from = Carbon::now() + ->startOfDay() + ->format('Y-m-d'); + $to = Carbon::now() + ->endOfDay() + ->format('Y-m-d'); + return [ + 'sort_by' => 'popularity.desc', + 'air_date.gte' => $from, + 'air_date.lte' => $to, + ]; + default: + return []; + } + } +} diff --git a/app/Actions/Demo/GenerateDemoAnimeVideos.php b/app/Actions/Demo/GenerateDemoAnimeVideos.php new file mode 100755 index 0000000..106c982 --- /dev/null +++ b/app/Actions/Demo/GenerateDemoAnimeVideos.php @@ -0,0 +1,97 @@ +write('Generating demo anime videos... ', true); + + $this->createTitleLinks(); + $this->createEpisodeLinks(); + } + + private function createTitleLinks(): void + { + Title::where('is_series', false) + ->select('id') + ->whereDoesntHave('videos', function ($query) { + $query->where('category', 'full')->where('origin', 'local'); + }) + ->chunkById(100, function (Collection $titles) { + $titleVideos = $titles + ->pluck('id') + ->map(fn($titleId) => $this->getVideosData($titleId)) + ->flatten(1); + Video::insert($titleVideos->toArray()); + }); + } + + private function createEpisodeLinks(): void + { + Episode::whereDoesntHave('videos', function ($query) { + $query->where('category', 'full')->where('origin', 'local'); + })->chunkById(100, function (Collection $episodes) { + $episodeVideos = $episodes + ->map( + fn(Episode $episode) => $this->getVideosData( + $episode->title_id, + $episode, + ), + ) + ->flatten(1); + Video::insert($episodeVideos->toArray()); + }); + } + + private function getVideosData($titleId, Episode $episode = null): array + { + $sharedData = [ + 'category' => 'full', + 'title_id' => $titleId, + 'season_num' => $episode?->season_number, + 'episode_num' => $episode?->episode_number, + 'episode_id' => $episode?->id, + 'origin' => 'local', + 'approved' => true, + 'updated_at' => Carbon::now(), + 'user_id' => 1, + ]; + + $urls = [ + 'https://www.youtube.com/embed/ByXuk9QqQkk', + 'https://player.vimeo.com/video/208890816', + 'https://www.dailymotion.com/embed/video/x4qi23d', + 'https://www.youtube.com/embed/xU47nhruN-Q', + 'https://google.com', + ]; + + $languages = ['en', 'ru', 'fr', 'en', 'en']; + + $videos = []; + for ($i = 0; $i <= 4; $i++) { + $num = $i + 1; + + $videos[] = array_merge($sharedData, [ + 'name' => "Mirror $num", + 'src' => $urls[$i], + 'quality' => $i === 3 ? '4k' : 'hd', + 'language' => $languages[$i], + 'type' => $i === 4 ? 'external' : 'embed', + 'upvotes' => rand(1, 200), + 'downvotes' => rand(1, 30), + 'created_at' => Carbon::now()->subDay(), + ]); + } + return $videos; + } +} diff --git a/app/Actions/Demo/GenerateDemoComments.php b/app/Actions/Demo/GenerateDemoComments.php new file mode 100755 index 0000000..bd651e6 --- /dev/null +++ b/app/Actions/Demo/GenerateDemoComments.php @@ -0,0 +1,131 @@ +truncate(); + DB::table('comment_votes')->truncate(); + $this->currentId = DB::table('comments')->max('id') + 1; + + $demoEmails = collect( + json_decode( + file_get_contents(app_path('Actions/Demo/demo-users.json')), + true, + ), + )->pluck('email'); + $this->users = User::whereIn('email', $demoEmails)->get(); + + // movies + $this->generateFor( + Title::where('release_date', '<', now()->subDays(6)) + ->where('is_series', false) + ->where('popularity', '>=', 10), + $variant === 'anime' + ? app_path('Actions/Demo/demo-anime-comments.json') + : app_path('Actions/Demo/demo-movie-comments.json'), + ); + + // series + $this->generateFor( + Title::where('release_date', '<', now()->subDays(6)) + ->withCount('episodes') + ->where('is_series', true) + ->where('popularity', '>=', 10) + ->has('episodes', '<', 400), + $variant === 'anime' + ? app_path('Actions/Demo/demo-anime-comments.json') + : app_path('Actions/Demo/demo-series-comments.json'), + ); + + // episodes + $this->generateFor( + Episode::where('release_date', '<', now()->subDays(6))->whereHas( + 'title', + function ($query) { + $query->where('popularity', '>=', 1); + }, + ), + $variant === 'anime' + ? app_path('Actions/Demo/demo-anime-comments.json') + : app_path('Actions/Demo/demo-episode-comments.json'), + ); + } + + protected function generateFor(Builder $builder, string $path): void + { + $query = $builder->has('comments', '<', 40)->select('id'); + $count = $query->count(); + + if (!$count) { + return; + } + + $output = new ConsoleOutput(); + $progressBar = new ProgressBar($output, $count); + $progressBar->start(); + + $output->write( + "Generating comments for {$builder->getModel()->getTable()}", + true, + ); + + $userSequence = new Sequence(...$this->users->pluck('id')->toArray()); + $data = json_decode(file_get_contents($path), true); + + $itemComments = collect($data) + ->slice(0, rand(40, 118)) + ->map(function ($comment) use ($userSequence) { + $date = now() + ->subMonth(rand(0, 2)) + ->subDays(rand(1, 12)); + return [ + 'user_id' => $userSequence(), + 'content' => $comment['comment'], + 'upvotes' => rand(5, 200), + 'downvotes' => rand(0, 20), + 'reports_count' => rand(0, 3), + 'created_at' => $date, + 'updated_at' => $date, + ]; + }); + + $query + ->lazyById(100) + ->each(function ($item) use ($progressBar, $itemComments) { + $payload = $itemComments->map(function ($comment) use ($item) { + $data = array_merge($comment, [ + 'id' => $this->currentId, + 'path' => $this->encodePath($this->currentId), + 'commentable_id' => $item->id, + 'commentable_type' => $item->getMorphClass(), + ]); + $this->currentId++; + return $data; + }); + DB::table('comments')->insert($payload->toArray()); + $progressBar->advance(); + }); + + $progressBar->finish(); + } +} diff --git a/app/Actions/Demo/GenerateDemoReviews.php b/app/Actions/Demo/GenerateDemoReviews.php new file mode 100755 index 0000000..0a3b7db --- /dev/null +++ b/app/Actions/Demo/GenerateDemoReviews.php @@ -0,0 +1,100 @@ +truncate(); + DB::table('review_feedback')->truncate(); + + $demoEmails = collect( + json_decode( + file_get_contents(app_path('Actions/Demo/demo-users.json')), + true, + ), + )->pluck('email'); + $this->users = User::whereIn('email', $demoEmails)->get(); + + $this->generateFor('movie'); + $this->generateFor('series'); + } + + protected function generateFor(string $type): void + { + $query = Title::where('is_series', $type == 'series') + ->where('popularity', '>=', 10) + ->where('release_date', '<', now()->subDays(6)) + ->has('reviews', '<', 40) + ->select('id'); + $count = $query->count(); + + if ($count === 0) { + return; + } + + $output = new ConsoleOutput(); + $progressBar = new ProgressBar($output, $count); + $progressBar->start(); + + $output->write( + "Generating reviews for {$query->getModel()->getTable()}", + true, + ); + + $userSequence = new Sequence(...$this->users->pluck('id')->toArray()); + + $data = json_decode( + file_get_contents(app_path("Actions/Demo/demo-$type-reviews.json")), + true, + ); + + $movieReviews = collect($data) + ->slice(0, rand(40, $this->users->count())) + ->map(function ($review) use ($userSequence) { + $date = now() + ->subMonth(rand(0, 2)) + ->subDays(rand(1, 12)); + return [ + 'user_id' => $userSequence(), + 'title' => $review['title'], + 'body' => $review['body'], + 'score' => $review['score'], + 'has_text' => true, + 'helpful_count' => rand(10, 200), + 'not_helpful_count' => rand(0, 20), + 'created_at' => $date, + 'updated_at' => $date, + ]; + }); + + $query + ->lazyById(100) + ->each(function ($movie) use ($movieReviews, $progressBar) { + $payload = $movieReviews->map( + fn($review) => array_merge($review, [ + 'reviewable_id' => $movie->id, + 'reviewable_type' => Title::MODEL_TYPE, + ]), + ); + DB::table('reviews')->insert($payload->toArray()); + $progressBar->advance(); + }); + + $progressBar->finish(); + } +} diff --git a/app/Actions/Demo/GenerateDemoStreamVideos.php b/app/Actions/Demo/GenerateDemoStreamVideos.php new file mode 100755 index 0000000..d72a749 --- /dev/null +++ b/app/Actions/Demo/GenerateDemoStreamVideos.php @@ -0,0 +1,185 @@ + 'Demo HLS', + 'src' => + 'https://stream.mux.com/buWV5Srtc9vUQTWmgpTFkDNlA3zImB7C9CQ2CVgkZpI.m3u8', + 'type' => 'stream', + 'quality' => '1080p', + ], + [ + 'name' => 'Demo HLS', + 'src' => + 'https://stream.mux.com/Rus46I1a5LSATgJ6YDhbDo024F7qHEYZBTbOxRZJdIn4.m3u8', + 'type' => 'stream', + 'quality' => '1080p', + ], + [ + 'name' => 'Demo HLS', + 'src' => + 'https://stream.mux.com/E7hs5dNds85023rGvL4rrAjPI02BpRE2024XyFnjx007oj4.m3u8', + 'type' => 'stream', + 'quality' => '1080p', + ], + [ + 'name' => 'Demo HLS', + 'src' => + 'https://stream.mux.com/Q20238p74cLNL4Bm2ivdjGnSozST6Gec2Ym1EuDnCMJI.m3u8', + 'type' => 'stream', + 'quality' => '1080p', + ], + [ + 'name' => 'Demo HLS', + 'src' => + 'https://stream.mux.com/spmRS3NLqWHkkBGEYM4KRhj6Hytnat00AnPS8v3hj9qA.m3u8', + 'type' => 'stream', + 'quality' => '1080p', + ], + [ + 'name' => 'Demo WebM', + 'src' => 'storage/demo-videos/sprite-fright/sprite-fright.webm', + 'type' => 'video', + 'quality' => '1080p', + 'captions' => [ + [ + 'name' => 'English', + 'language' => 'en', + 'url' => + 'storage/demo-videos/sprite-fright/subs/sprite-fright.en.vtt', + ], + [ + 'name' => 'Russian', + 'language' => 'ru', + 'url' => + 'storage/demo-videos/sprite-fright/subs/sprite-fright.ru.vtt', + ], + [ + 'name' => 'German', + 'language' => 'de', + 'url' => + 'storage/demo-videos/sprite-fright/subs/sprite-fright.de.vtt', + ], + [ + 'name' => 'Italian', + 'language' => 'it', + 'url' => + 'storage/demo-videos/sprite-fright/subs/sprite-fright.it.vtt', + ], + ], + ], + ]; + + protected Sequence $demoVideoSequence; + + public function execute(): void + { + $this->demoVideoSequence = new Sequence(...$this->demoVideos); + + $output = new ConsoleOutput(); + $output->write('Generating demo stream videos... ', true); + + $this->createVideosForAllMovies(); + $this->createEpisodeVideos(); + $this->createCaptions(); + } + + private function createVideosForAllMovies(): void + { + Title::where('is_series', false) + ->whereDoesntHave( + 'videos', + fn($query) => $query + ->where('origin', 'local') + ->where('category', 'full'), + ) + ->select('id') + ->chunkById(100, function (Collection $titles) { + $titleVideos = $titles->map( + fn($title) => $this->getVideoData($title->id), + ); + Video::insert($titleVideos->toArray()); + }); + } + + private function createCaptions(): void + { + collect($this->demoVideos) + ->filter(fn($video) => isset($video['captions'])) + ->each(function ($data) { + Video::where('origin', 'local') + ->where('category', 'full') + ->where('name', $data['name']) + ->whereDoesntHave('captions') + ->chunkById(100, function (Collection $videos) use ($data) { + $captionPayload = $videos->flatMap( + fn(Video $video) => array_map( + fn($captionData) => [ + 'name' => $captionData['name'], + 'language' => $captionData['language'], + 'url' => $captionData['url'], + 'video_id' => $video->id, + 'created_at' => now(), + 'updated_at' => now(), + ], + $data['captions'], + ), + ); + VideoCaption::insert($captionPayload->toArray()); + }); + }); + } + + private function createEpisodeVideos(): void + { + Episode::whereDoesntHave( + 'videos', + fn($query) => $query + ->where('origin', 'local') + ->where('category', 'full'), + )->chunkById(100, function (Collection $episodes) { + $episodeVideos = $episodes->map( + fn(Episode $episode) => $this->getVideoData( + $episode->title_id, + $episode, + ), + ); + Video::insert($episodeVideos->toArray()); + }); + } + + private function getVideoData(int $titleId, Episode $episode = null): array + { + $sequence = $this->demoVideoSequence; + $data = $sequence(); + + return [ + 'name' => $data['name'], + 'src' => $data['src'], + 'type' => $data['type'], + 'category' => 'full', + 'quality' => $data['quality'], + 'title_id' => $titleId, + 'season_num' => $episode?->season_number, + 'episode_num' => $episode?->episode_number, + 'episode_id' => $episode?->id, + 'origin' => 'local', + 'approved' => true, + 'created_at' => now(), + 'updated_at' => now(), + 'language' => 'en', + ]; + } +} diff --git a/app/Actions/Demo/GenerateDemoUsers.php b/app/Actions/Demo/GenerateDemoUsers.php new file mode 100755 index 0000000..fad415c --- /dev/null +++ b/app/Actions/Demo/GenerateDemoUsers.php @@ -0,0 +1,53 @@ +unique('email'); + + $existing = User::whereIn('email', $data->pluck('email'))->get(); + $unique = $data->reject( + fn($user) => $existing->contains('email', $user['email']), + ); + + if ($unique->isEmpty()) { + return; + } + + $output = new ConsoleOutput(); + $output->write('Generating demo users... ', true); + + $avatarSequence = new Sequence(...range(1, 75)); + + $users = $unique->map(function ($user) use ($avatarSequence) { + $number = $avatarSequence(); + $gender = strtolower($user['gender']); + return [ + 'email' => $user['email'], + 'first_name' => $user['first_name'], + 'last_name' => $user['last_name'], + 'gender' => $gender, + 'password' => Str::random(), + 'email_verified_at' => now(), + 'avatar' => "https://xsgames.co/randomusers/assets/avatars/$gender/$number.jpg", + ]; + }); + + User::insert($users->toArray()); + } +} diff --git a/app/Actions/Demo/GenerateDemoVideoVotes.php b/app/Actions/Demo/GenerateDemoVideoVotes.php new file mode 100755 index 0000000..9721f2c --- /dev/null +++ b/app/Actions/Demo/GenerateDemoVideoVotes.php @@ -0,0 +1,41 @@ +truncate(); + + $count = Video::where('upvotes', '<', 60)->count(); + + if ($count == 0) { + return; + } + + $output = new ConsoleOutput(); + $output->write('Generating video votes... ', true); + $progressBar = new ProgressBar($output, $count); + $progressBar->start(); + + Video::select('id') + ->where('upvotes', '<', 60) + ->lazyById(100) + ->each(function (Video $video) use ($progressBar) { + $video->update([ + 'upvotes' => rand(60, 2100), + 'downvotes' => rand(50, 150), + ]); + $progressBar->advance(); + }); + + $progressBar->finish(); + } +} diff --git a/app/Actions/Demo/demo-anime-comments.json b/app/Actions/Demo/demo-anime-comments.json new file mode 100755 index 0000000..74665a1 --- /dev/null +++ b/app/Actions/Demo/demo-anime-comments.json @@ -0,0 +1,161 @@ +[ + { + "comment": "OMG, this anime had me on the edge of my seat! The action scenes were so freaking epic, and the plot was just mind-blowing." + }, + { + "comment": "Dude, I can't get enough of the awesome animation in this series. It's like a visual feast, man!" + }, + { + "comment": "This anime is a total blast, yo! It's got a bit of everything – laughs, romance, and crazy adventures." + }, + { + "comment": "Bro, just when I thought I had it all figured out, this anime threw me for a loop! Loved all those surprises, man." + }, + { + "comment": "The character development in this anime is freaking awesome! I got super attached to the main crew and their struggles." + }, + { + "comment": "No lie, I binge-watched this entire series in one sitting. It's hella addictive, worth staying up all night for!" + }, + { + "comment": "If you want a feel-good anime with a positive message, this one's a winner. Left me with a big ol' smile, man!" + }, + { + "comment": "The voice acting in this anime is off the charts, bro! The emotions hit you right in the gut." + }, + { + "comment": "As an anime fanatic, I'm telling you, this one's the real deal. A hidden gem that needs more love, dude!" + }, + { + "comment": "The world-building in this anime is sick, man! I got lost in that crazy universe, couldn't get enough of it!" + }, + { + "comment": "Bro, the art style is out of this world! Every frame is like a work of art, totally blows your mind!" + }, + { + "comment": "Ngl, I haven't cried this much over an anime in forever. The feels hit you right in the heart, dude!" + }, + { + "comment": "This anime took me on an emotional rollercoaster, man! I laughed, I cried, I freaking loved it all!" + }, + { + "comment": "The fight scenes are straight-up epic! They get your blood pumping, and you can't take your eyes off 'em." + }, + { + "comment": "Man, I'm jamming to the sick tunes in this anime. The soundtrack is fire, sets the perfect mood!" + }, + { + "comment": "If you're into mind-bending plots and crazy twists, you're gonna be mind-blown by this anime, bro!" + }, + { + "comment": "The supporting characters in this anime are just as rad as the main crew. I dig 'em all!" + }, + { + "comment": "This anime knows how to balance humor and serious stuff. Keeps you entertained the whole time!" + }, + { + "comment": "This anime ain't afraid to tackle deep questions, man. Makes you think about life and stuff, you know?" + }, + { + "comment": "Big props to this anime for showing respect to different cultures. It feels genuine, man." + }, + { + "comment": "The pacing in this anime is on point, dude. It keeps the story moving without dragging it out." + }, + { + "comment": "This anime's got a kickass mythology and lore. It adds a whole new dimension to the story!" + }, + { + "comment": "The friendships in this anime are so heartwarming, man. It's all about the bonds, and it hits you in the feels!" + }, + { + "comment": "Yo, I've been recommending this anime to everyone I know. It's a must-watch for any anime fan, no doubt!" + }, + { + "comment": "OMG, the humor in this anime had me cracking up big time! Laughed my guts out in every episode!" + }, + { + "comment": "I'm obsessed with the crazy character designs, man. Each one is so unique and stands out!" + }, + { + "comment": "This anime is full of epic quotes that stick with you, dude. You'll be saying 'em all day long!" + }, + { + "comment": "I wish I could live in the world of this anime, man. The universe is so freaking cool!" + }, + { + "comment": "The character arcs in this anime are next-level, bro. You see 'em grow and change, and it's awesome!" + }, + { + "comment": "The action sequences are out of this world, man. They get your heart racing, no lie!" + }, + { + "comment": "Yo, this anime left me with a rollercoaster of emotions. Happy, sad, everything in between!" + }, + { + "comment": "I can't get enough of the catchy opening and ending themes, man. They get stuck in your head!" + }, + { + "comment": "The attention to detail in the animation is sick. It's all about those little things that make it pop!" + }, + { + "comment": "The villain in this anime is wicked! Love to hate 'em, bro!" + }, + { + "comment": "The character relationships feel so real and relatable, man. You get invested in their lives!" + }, + { + "comment": "OMG, the plot twists had me shook, bro. I never saw 'em coming, that's for sure!" + }, + { + "comment": "This anime dives deep into life's big questions, man. Makes you ponder and stuff." + }, + { + "comment": "The art and animation quality never disappoint. It's top-notch all the way, dude!" + }, + { + "comment": "The emotional depth of this anime hits you hard, man. It's a rollercoaster ride!" + }, + { + "comment": "I couldn't stop watching once I started, dude. It's totally addicting!" + }, + { + "comment": "This anime strikes the perfect balance between laughs and heartwarming moments. So darn enjoyable!" + }, + { + "comment": "The character backstories are on point, bro. It adds layers of awesomeness to the story!" + }, + { + "comment": "Yo, I'm impressed by how this anime respects different cultures. It's all about that diversity, man!" + }, + { + "comment": "The world-building in this anime is so freaking cool. It's like a whole new universe to explore!" + }, + { + "comment": "This anime's all about friendship, love, and courage, man. Hits you right in the feels!" + }, + { + "comment": "The plot keeps you guessing, bro. It's like a wild ride with twists at every turn!" + }, + { + "comment": "The character dynamics and chemistry are fire, man. It's a joy to watch 'em interact!" + }, + { + "comment": "OMG, the art style is mind-blowing! It's like a feast for the eyes, dude!" + }, + { + "comment": "I appreciate how this anime tackles real-world issues while still being hella entertaining." + }, + { + "comment": "The soundtrack adds so much to the anime, bro. It sets the mood perfectly!" + }, + { + "comment": "This anime got me right in the feels, man. Laughed, cried, and cheered for the characters!" + }, + { + "comment": "The world-building and lore in this anime are insane, dude. It's a whole new dimension!" + }, + { + "comment": "This anime left me with a mix of emotions – from laughter to tears. Freaking unforgettable!" + } +] diff --git a/app/Actions/Demo/demo-episode-comments.json b/app/Actions/Demo/demo-episode-comments.json new file mode 100755 index 0000000..14c4a63 --- /dev/null +++ b/app/Actions/Demo/demo-episode-comments.json @@ -0,0 +1,365 @@ +[ + { + "comment": "OMG, this episode blew my mind! So many unexpected twists!" + }, + { + "comment": "Just watched this episode and I'm speechless. It's a game-changer!" + }, + { + "comment": "The suspense in this episode is unreal. Kept me on the edge of my seat!" + }, + { + "comment": "This episode had me laughing out loud. So hilarious!" + }, + { + "comment": "The emotional depth in this episode is incredible. It hit me right in the feels!" + }, + { + "comment": "Just finished watching this episode and I'm in awe. So well-crafted!" + }, + { + "comment": "The acting in this episode is phenomenal. Each performance is top-notch!" + }, + { + "comment": "This episode had me hooked from start to finish. So captivating!" + }, + { + "comment": "The plot development in this episode is mind-blowing. It's building up to something big!" + }, + { + "comment": "Just witnessed the biggest cliffhanger in this episode. I need to know what happens next!" + }, + { + "comment": "The chemistry between the characters in this episode is electric. I love their interactions!" + }, + { + "comment": "This episode had me on an emotional rollercoaster. I laughed, I cried, I felt it all!" + }, + { + "comment": "The cinematography in this episode is stunning. Each shot is visually breathtaking!" + }, + { + "comment": "This episode is a rollercoaster ride of emotions. It's intense and heart-wrenching!" + }, + { + "comment": "The dialogue in this episode is sharp and impactful. It kept me engaged!" + }, + { + "comment": "This episode is a masterpiece. The writing, the acting, everything is on point!" + }, + { + "comment": "The character growth in this episode is remarkable. They're evolving so beautifully!" + }, + { + "comment": "This episode left me with so many questions. I can't wait for the next one!" + }, + { + "comment": "The chemistry between the actors in this episode is undeniable. They have great synergy!" + }, + { + "comment": "This episode is a rollercoaster of emotions. It had me on the edge of my seat!" + }, + { + "comment": "The cinematography in this episode is stunning. It adds another layer of depth!" + }, + { + "comment": "This episode is a turning point in the series. It's a game-changer!" + }, + { + "comment": "The performances in this episode are outstanding. The actors brought their A-game!" + }, + { + "comment": "This episode kept me guessing till the very end. So many surprises!" + }, + { + "comment": "The writing in this episode is brilliant. The storytelling is top-notch!" + }, + { + "comment": "This episode had me on an emotional rollercoaster. I'm still recovering!" + }, + { + "comment": "The intensity in this episode is off the charts. It had me at the edge of my seat!" + }, + { + "comment": "Just watched this episode and I'm shook. It took the series to a whole new level!" + }, + { + "comment": "The cinematography in this episode is breathtaking. Each shot is a work of art!" + }, + { + "comment": "This episode had me hooked from the first minute. Such a gripping storyline!" + }, + { + "comment": "The character dynamics in this episode are so well-executed. I love seeing their relationships!" + }, + { + "comment": "This episode is a rollercoaster of emotions. It made me laugh and cry!" + }, + { + "comment": "The plot twists in this episode are mind-blowing. They kept me on my toes!" + }, + { + "comment": "This episode left me speechless. It's a game-changer!" + }, + { + "comment": "The acting in this episode is exceptional. The performances are so powerful!" + }, + { + "comment": "This episode had me at the edge of my seat. So intense and suspenseful!" + }, + { + "comment": "The storytelling in this episode is masterful. It's a captivating journey!" + }, + { + "comment": "This episode had me in tears. It touched my heart in ways I didn't expect!" + }, + { + "comment": "The chemistry between the characters in this episode is fire. I ship them so hard!" + }, + { + "comment": "This episode is a rollercoaster ride. It had me gasping for breath!" + }, + { + "comment": "The cinematography in this episode is stunning. Each frame is like a painting!" + }, + { + "comment": "This episode is a game-changer. It took the series to a whole new level!" + }, + { + "comment": "The performances in this episode are outstanding. The actors brought so much depth!" + }, + { + "comment": "This episode had me at the edge of my seat. I couldn't look away!" + }, + { + "comment": "The writing in this episode is exceptional. It's smart and thought-provoking!" + }, + { + "comment": "This episode had me in tears. It's an emotional rollercoaster!" + }, + { + "comment": "The tension in this episode is palpable. It kept me on the edge of my seat!" + }, + { + "comment": "Just watched this episode and I'm in awe. It's a game-changer!" + }, + { + "comment": "The cinematography in this episode is breathtaking. Each shot is visually stunning!" + }, + { + "comment": "This episode had me hooked from the start. Such a compelling story!" + }, + { + "comment": "The character development in this episode is remarkable. I love seeing their growth!" + }, + { + "comment": "This episode is a rollercoaster of emotions. It made me laugh and cry!" + }, + { + "comment": "The plot twists in this episode blew my mind. I didn't see them coming!" + }, + { + "comment": "This episode left me speechless. It's a turning point in the series!" + }, + { + "comment": "The acting in this episode is outstanding. The performances are so powerful!" + }, + { + "comment": "This episode had me on the edge of my seat. It's so suspenseful!" + }, + { + "comment": "The storytelling in this episode is brilliant. It kept me engaged throughout!" + }, + { + "comment": "This episode touched my heart. It's emotional and impactful!" + }, + { + "comment": "The chemistry between the characters in this episode is electric. Their interactions are captivating!" + }, + { + "comment": "This episode is a rollercoaster ride. It had me on an emotional high!" + }, + { + "comment": "The cinematography in this episode is stunning. Each shot is visually breathtaking!" + }, + { + "comment": "This episode is a game-changer. It took the series to a whole new level!" + }, + { + "comment": "The performances in this episode are outstanding. The actors delivered powerful portrayals!" + }, + { + "comment": "This episode had me at the edge of my seat. I couldn't look away!" + }, + { + "comment": "The writing in this episode is exceptional. It's gripping and thought-provoking!" + }, + { + "comment": "This episode had me in tears. It's an emotional rollercoaster!" + }, + { + "comment": "The tension in this episode is palpable. It kept me on the edge of my seat!" + }, + { + "comment": "Just watched this episode and my mind is blown. It's a game-changer!" + }, + { + "comment": "The suspense in this episode is unreal. Kept me on the edge of my seat!" + }, + { + "comment": "This episode had me laughing out loud. So hilarious!" + }, + { + "comment": "The emotional depth in this episode is incredible. It hit me right in the feels!" + }, + { + "comment": "Just finished watching this episode and I'm in awe. So well-crafted!" + }, + { + "comment": "The acting in this episode is phenomenal. Each performance is top-notch!" + }, + { + "comment": "This episode had me hooked from start to finish. So captivating!" + }, + { + "comment": "The plot development in this episode is mind-blowing. It's building up to something big!" + }, + { + "comment": "Just witnessed the biggest cliffhanger in this episode. I need to know what happens next!" + }, + { + "comment": "The chemistry between the characters in this episode is electric. I love their interactions!" + }, + { + "comment": "This episode had me on an emotional rollercoaster. I laughed, I cried, I felt it all!" + }, + { + "comment": "The cinematography in this episode is stunning. Each shot is visually breathtaking!" + }, + { + "comment": "This episode is a rollercoaster ride of emotions. It's intense and heart-wrenching!" + }, + { + "comment": "The dialogue in this episode is sharp and impactful. It kept me engaged!" + }, + { + "comment": "This episode is a masterpiece. The writing, the acting, everything is on point!" + }, + { + "comment": "The character growth in this episode is remarkable. They're evolving so beautifully!" + }, + { + "comment": "This episode left me with so many questions. I can't wait for the next one!" + }, + { + "comment": "The chemistry between the actors in this episode is undeniable. They have great synergy!" + }, + { + "comment": "This episode is a rollercoaster of emotions. It made me laugh and cry!" + }, + { + "comment": "The plot twists in this episode are mind-blowing. They kept me on my toes!" + }, + { + "comment": "This episode left me speechless. It's a game-changer!" + }, + { + "comment": "The acting in this episode is exceptional. The performances are so powerful!" + }, + { + "comment": "This episode had me at the edge of my seat. So intense and suspenseful!" + }, + { + "comment": "The storytelling in this episode is masterful. It's a captivating journey!" + }, + { + "comment": "This episode had me in tears. It touched my heart in ways I didn't expect!" + }, + { + "comment": "The chemistry between the characters in this episode is fire. I ship them so hard!" + }, + { + "comment": "This episode is a rollercoaster ride. It had me gasping for breath!" + }, + { + "comment": "The cinematography in this episode is stunning. Each frame is like a painting!" + }, + { + "comment": "This episode is a game-changer. It took the series to a whole new level!" + }, + { + "comment": "The performances in this episode are outstanding. The actors brought so much depth!" + }, + { + "comment": "This episode had me on the edge of my seat. I couldn't look away!" + }, + { + "comment": "The writing in this episode is exceptional. It's gripping and thought-provoking!" + }, + { + "comment": "This episode had me in tears. It's an emotional rollercoaster!" + }, + { + "comment": "The tension in this episode is palpable. It kept me on the edge of my seat!" + }, + { + "comment": "Just watched this episode and I'm in awe. It's a game-changer!" + }, + { + "comment": "The cinematography in this episode is breathtaking. Each shot is visually stunning!" + }, + { + "comment": "This episode had me hooked from the start. Such a compelling story!" + }, + { + "comment": "The character development in this episode is remarkable. I love seeing their growth!" + }, + { + "comment": "This episode is a rollercoaster of emotions. It made me laugh and cry!" + }, + { + "comment": "The plot twists in this episode blew my mind. I didn't see them coming!" + }, + { + "comment": "This episode left me speechless. It's a turning point in the series!" + }, + { + "comment": "The acting in this episode is outstanding. The performances are so powerful!" + }, + { + "comment": "This episode had me on the edge of my seat. It's so suspenseful!" + }, + { + "comment": "The storytelling in this episode is brilliant. It kept me engaged throughout!" + }, + { + "comment": "This episode touched my heart. It's emotional and impactful!" + }, + { + "comment": "The chemistry between the characters in this episode is electric. Their interactions are captivating!" + }, + { + "comment": "This episode is a rollercoaster ride. It had me on an emotional high!" + }, + { + "comment": "The cinematography in this episode is stunning. Each shot is visually breathtaking!" + }, + { + "comment": "This episode is a game-changer. It took the series to a whole new level!" + }, + { + "comment": "The performances in this episode are outstanding. The actors delivered powerful portrayals!" + }, + { + "comment": "This episode had me at the edge of my seat. I couldn't look away!" + }, + { + "comment": "The writing in this episode is exceptional. It's gripping and thought-provoking!" + }, + { + "comment": "This episode had me in tears. It's an emotional rollercoaster!" + }, + { + "comment": "The tension in this episode is palpable. It kept me on the edge of my seat!" + } +] diff --git a/app/Actions/Demo/demo-movie-comments.json b/app/Actions/Demo/demo-movie-comments.json new file mode 100755 index 0000000..a177593 --- /dev/null +++ b/app/Actions/Demo/demo-movie-comments.json @@ -0,0 +1,356 @@ +[ + { + "comment": "OMG, this movie is mind-blowing! Couldn't take my eyes off the screen!" + }, + { + "comment": "Just watched this film and it's a total rollercoaster ride. So intense!" + }, + { + "comment": "The visuals in this movie are insane. Such stunning cinematography!" + }, + { + "comment": "Lead actor in this one is straight-up killing it. Their performance was on point!" + }, + { + "comment": "This movie had me on the edge of my seat the whole time. Non-stop excitement!" + }, + { + "comment": "The cast in this film is awesome. They brought so much energy to the story!" + }, + { + "comment": "Laughed my head off watching this comedy flick. It's hilarious from start to finish!" + }, + { + "comment": "Action scenes in this movie are epic. Heart-pounding and adrenaline-fueled!" + }, + { + "comment": "This film had me in tears one moment and then cheering like crazy. So many emotions!" + }, + { + "comment": "The soundtrack in this movie is fire. Can't get enough of those awesome tunes!" + }, + { + "comment": "Just saw this movie and it blew my mind! So much suspense and unexpected twists." + }, + { + "comment": "The setting in this film is breathtaking. It transports you to another world." + }, + { + "comment": "The chemistry between the actors in this movie is off the charts. They were perfect together!" + }, + { + "comment": "This movie had me hooked from the very beginning. Such an engaging storyline!" + }, + { + "comment": "The humor in this film is on point. I was laughing throughout!" + }, + { + "comment": "The special effects in this movie are mind-blowing. So realistic and visually stunning!" + }, + { + "comment": "The performances in this film are outstanding. The actors truly brought their characters to life." + }, + { + "comment": "I can't get over the incredible plot twists in this movie. Kept me guessing till the end!" + }, + { + "comment": "This movie touched my heart. I felt all the emotions deeply." + }, + { + "comment": "The action sequences in this film are like nothing I've seen before. Jaw-dropping!" + }, + { + "comment": "Just watched this movie and it exceeded all my expectations. Highly recommended!" + }, + { + "comment": "The cinematography in this film is stunning. Every frame is a work of art." + }, + { + "comment": "The performances in this movie blew me away. So raw and powerful!" + }, + { + "comment": "This movie is a rollercoaster of emotions. Laughter, tears, and everything in between!" + }, + { + "comment": "The chemistry between the lead actors is off the charts. Their on-screen presence is electrifying!" + }, + { + "comment": "Couldn't stop talking about this movie after watching it. It left a lasting impression!" + }, + { + "comment": "The dialogue in this film is so witty and clever. I was quoting lines for days!" + }, + { + "comment": "This movie had me at the edge of my seat throughout. Suspenseful and thrilling!" + }, + { + "comment": "The soundtrack in this movie is pure perfection. It sets the mood so well!" + }, + { + "comment": "Just watched this movie and I'm in awe. It's a masterpiece in every aspect!" + }, + { + "comment": "The storytelling in this film is top-notch. It captivated me from start to finish." + }, + { + "comment": "The performances in this movie are Oscar-worthy. So much talent on screen!" + }, + { + "comment": "This movie is a total crowd-pleaser. Everyone should watch it!" + }, + { + "comment": "The suspense in this film had me on the edge of my seat. Nerve-wracking and intense!" + }, + { + "comment": "The cinematography in this movie is breathtaking. Each shot is visually stunning!" + }, + { + "comment": "The characters in this film are so relatable. I felt like I knew them personally." + }, + { + "comment": "This movie kept me guessing till the very end. So many unexpected twists!" + }, + { + "comment": "The performances in this film are exceptional. I was completely immersed in the story." + }, + { + "comment": "Just watched this movie and I'm speechless. It's a cinematic masterpiece!" + }, + { + "comment": "The humor in this film had me laughing out loud. Pure comedic genius!" + }, + { + "comment": "This movie is a visual feast. The production design is incredible!" + }, + { + "comment": "The chemistry between the lead actors is undeniable. Their interactions are electric!" + }, + { + "comment": "Couldn't get enough of this movie. I wish it never ended!" + }, + { + "comment": "The cinematography in this film is stunning. It captured the essence of the story perfectly." + }, + { + "comment": "The performances in this movie are outstanding. Each actor brought something unique to their role." + }, + { + "comment": "This movie took me on an emotional rollercoaster. I laughed, I cried, and everything in between!" + }, + { + "comment": "The action sequences in this film are jaw-dropping. It's like an adrenaline rush!" + }, + { + "comment": "Just finished watching this movie and I'm blown away. It's a must-see!" + }, + { + "comment": "The visuals in this film are stunning. I was in awe of the breathtaking cinematography!" + }, + { + "comment": "The performances in this movie are phenomenal. The actors gave it their all!" + }, + { + "comment": "This movie had me hooked from the beginning till the end. Such a gripping storyline!" + }, + { + "comment": "The humor in this film is spot on. I couldn't stop laughing!" + }, + { + "comment": "The special effects in this movie are mind-blowing. So realistic and visually stunning!" + }, + { + "comment": "The cast in this movie is fantastic. Each actor brought depth and charisma to their character." + }, + { + "comment": "This movie kept me on the edge of my seat. So many unexpected twists and turns!" + }, + { + "comment": "The emotions in this film hit me hard. I laughed, I cried, and I felt everything in between." + }, + { + "comment": "The action sequences in this movie are absolutely thrilling. It's a wild ride from start to finish!" + }, + { + "comment": "Just watched this movie and it blew my mind! The story, the performances, everything was incredible." + }, + { + "comment": "The cinematography in this film is breathtaking. Every shot is like a work of art!" + }, + { + "comment": "The performances in this movie are outstanding. The actors brought so much depth to their characters!" + }, + { + "comment": "This movie had me completely engrossed. I couldn't take my eyes off the screen!" + }, + { + "comment": "The humor in this film is on point. I couldn't stop laughing!" + }, + { + "comment": "The special effects in this movie are mind-blowing. They added an extra layer of awesomeness!" + }, + { + "comment": "The cast in this movie is stellar. Each actor brought their A-game!" + }, + { + "comment": "This movie had me guessing till the end. So many plot twists and surprises!" + }, + { + "comment": "The performances in this film are outstanding. Each actor brought their character to life in a remarkable way." + }, + { + "comment": "Just watched this movie and I'm in awe. It's a cinematic masterpiece!" + }, + { + "comment": "The comedy in this film had me laughing so hard. It's pure comedic gold!" + }, + { + "comment": "This movie is visually stunning. The cinematography is breathtaking!" + }, + { + "comment": "The chemistry between the actors in this film is electric. They had amazing on-screen dynamics!" + }, + { + "comment": "Couldn't stop thinking about this movie after watching it. It left a lasting impression!" + }, + { + "comment": "The dialogue in this film is so witty and clever. It had me laughing out loud!" + }, + { + "comment": "This movie had me on the edge of my seat the whole time. Gripping and suspenseful!" + }, + { + "comment": "The soundtrack in this movie is perfect. It sets the mood and enhances every scene!" + }, + { + "comment": "Just watched this movie and I'm blown away. It's a must-see!" + }, + { + "comment": "The storytelling in this film is captivating. It kept me engaged from start to finish." + }, + { + "comment": "The performances in this movie are exceptional. Each actor delivered a powerful portrayal!" + }, + { + "comment": "This movie is a crowd-pleaser. It has something for everyone!" + }, + { + "comment": "The suspense in this film had me on the edge of my seat. It kept me guessing till the end!" + }, + { + "comment": "The cinematography in this movie is breathtaking. Each shot is visually stunning!" + }, + { + "comment": "The characters in this film are so relatable. I felt like I knew them personally." + }, + { + "comment": "This movie kept me on the edge of my seat. So many unexpected twists and turns!" + }, + { + "comment": "The performances in this film are exceptional. I was completely immersed in the story." + }, + { + "comment": "Just watched this movie and I'm speechless. It's a cinematic masterpiece!" + }, + { + "comment": "The humor in this film had me laughing out loud. Pure comedic genius!" + }, + { + "comment": "This movie is a visual feast. The production design is incredible!" + }, + { + "comment": "The chemistry between the lead actors is undeniable. Their on-screen interactions are electric!" + }, + { + "comment": "Couldn't get enough of this movie. I wish it never ended!" + }, + { + "comment": "The cinematography in this film is stunning. It captured the essence of the story perfectly." + }, + { + "comment": "The performances in this movie are outstanding. Each actor brought something unique to their role." + }, + { + "comment": "This movie took me on an emotional rollercoaster. I laughed, I cried, and everything in between!" + }, + { + "comment": "The action sequences in this film are jaw-dropping. It's like an adrenaline rush!" + }, + { + "comment": "Just finished watching this movie and I'm blown away. It's a must-see!" + }, + { + "comment": "The visuals in this film are stunning. I was in awe of the breathtaking cinematography!" + }, + { + "comment": "The performances in this movie are phenomenal. The actors gave it their all!" + }, + { + "comment": "This movie had me hooked from the beginning till the end. Such a gripping storyline!" + }, + { + "comment": "The humor in this film is spot on. I couldn't stop laughing!" + }, + { + "comment": "The special effects in this movie are mind-blowing. So realistic and visually stunning!" + }, + { + "comment": "The cast in this movie is fantastic. Each actor brought depth and charisma to their character." + }, + { + "comment": "This movie kept me guessing till the very end. So many unexpected twists!" + }, + { + "comment": "The performances in this film are exceptional. I was completely immersed in the story." + }, + { + "comment": "Just watched this movie and I'm in awe. It's a cinematic masterpiece!" + }, + { + "comment": "The cinematography in this film is breathtaking. Every shot is like a work of art!" + }, + { + "comment": "The performances in this movie are outstanding. Each actor brought so much depth to their characters!" + }, + { + "comment": "This movie had me completely engrossed. I couldn't take my eyes off the screen!" + }, + { + "comment": "The humor in this film is on point. I couldn't stop laughing!" + }, + { + "comment": "The special effects in this movie are mind-blowing. They added an extra layer of awesomeness!" + }, + { + "comment": "The cast in this movie is stellar. Each actor brought their A-game!" + }, + { + "comment": "This movie had me guessing till the end. So many plot twists and surprises!" + }, + { + "comment": "The performances in this film are outstanding. Each actor brought their character to life in a remarkable way." + }, + { + "comment": "Just watched this movie and I'm in awe. It's a cinematic masterpiece!" + }, + { + "comment": "The comedy in this film had me laughing so hard. It's pure comedic gold!" + }, + { + "comment": "This movie is visually stunning. The cinematography is breathtaking!" + }, + { + "comment": "The chemistry between the actors in this film is electric. They had amazing on-screen dynamics!" + }, + { + "comment": "Couldn't stop thinking about this movie after watching it. It left a lasting impression!" + }, + { + "comment": "The dialogue in this film is so witty and clever. It had me laughing out loud!" + }, + { + "comment": "This movie had me on the edge of my seat the whole time. Gripping and suspenseful!" + }, + { + "comment": "The soundtrack in this movie is perfect. It sets the mood and enhances every scene!" + } +] diff --git a/app/Actions/Demo/demo-movie-reviews.json b/app/Actions/Demo/demo-movie-reviews.json new file mode 100755 index 0000000..f0c76e1 --- /dev/null +++ b/app/Actions/Demo/demo-movie-reviews.json @@ -0,0 +1,523 @@ +[ + { + "title": "A Must-Watch!", + "body": "OMG, this movie is a total masterpiece! The storytelling, acting, and visuals are on another level. You gotta see it!", + "score": 9 + }, + { + "title": "So Thrilling and Action-Packed!", + "body": "Hold onto your seats, folks! This movie is an adrenaline rush from start to finish. The action scenes will blow your mind!", + "score": 8 + }, + { + "title": "Emotionally Powerful", + "body": "Get ready to feel all the feels! This movie dives deep into emotions and hits you right in the heart. It's a tearjerker, but in a good way!", + "score": 7 + }, + { + "title": "Laugh-Out-Loud Funny!", + "body": "If you're in need of a good laugh, this movie is the perfect remedy! The jokes are hilarious, and the humor is off the charts!", + "score": 8 + }, + { + "title": "Visually Jaw-Dropping", + "body": "Prepare to have your mind blown by the stunning visuals in this movie! The cinematography and special effects are out of this world!", + "score": 9 + }, + { + "title": "Keeps You Guessing!", + "body": "Whoa, this movie is a wild ride! It's full of suspense, unexpected twists, and turns that will leave you guessing until the very end!", + "score": 8 + }, + { + "title": "Heartwarming and Inspiring", + "body": "This movie will warm your heart and give you all the feels! It's inspiring, uplifting, and leaves you with a big smile on your face!", + "score": 9 + }, + { + "title": "Gets You Thinking", + "body": "Get ready to have your mind blown! This movie tackles deep themes that make you question everything. It'll keep you pondering for days!", + "score": 7 + }, + { + "title": "A Thrill Ride to Remember", + "body": "Buckle up, folks! This movie is a non-stop thrill ride that will have you at the edge of your seat, biting your nails, and craving for more!", + "score": 9 + }, + { + "title": "Mind-Blowing Performances", + "body": "The cast in this movie is absolutely incredible! They deliver mind-blowing performances that will leave you in awe. Kudos to the actors!", + "score": 8 + }, + { + "title": "Disappointing and Underwhelming", + "body": "I had high hopes for this movie, but it fell short. The plot was predictable, and the performances felt lackluster. Not worth the hype.", + "score": 4 + }, + { + "title": "Lacks Originality", + "body": "Unfortunately, this movie felt like a rehash of familiar tropes. The story offered nothing new, and I found myself bored and unengaged.", + "score": 3 + }, + { + "title": "A Missed Opportunity", + "body": "With such potential, this movie failed to deliver. The pacing was off, and the characters lacked depth. It's a forgettable experience.", + "score": 5 + }, + { + "title": "Impressive Cinematic Experience", + "body": "This movie is a visual feast for the eyes! The stunning cinematography and breathtaking set pieces create an immersive cinematic experience. Highly recommended!", + "score": 9 + }, + { + "title": "Captivating Storyline", + "body": "From beginning to end, this movie had me hooked with its compelling storyline. The twists and turns kept me engaged, eagerly waiting to see what happens next!", + "score": 8 + }, + { + "title": "Powerful and Thought-Provoking", + "body": "This movie tackles important themes and leaves a lasting impact. It makes you reflect on society and the human condition. Prepare for a thought-provoking journey!", + "score": 7 + }, + { + "title": "Hilarious and Heartwarming", + "body": "Laughter and warm fuzzies guaranteed! This movie strikes the perfect balance between comedy and heart, delivering memorable moments that will leave you smiling.", + "score": 8 + }, + { + "title": "Visually Stunning Masterpiece", + "body": "This movie is a visual marvel. The attention to detail, breathtaking visuals, and stunning cinematography create a feast for the eyes. A true cinematic masterpiece!", + "score": 9 + }, + { + "title": "Intriguing and Suspenseful", + "body": "Prepare for a nail-biting experience! This movie keeps you on the edge of your seat with its gripping plot and suspenseful sequences. It will leave you guessing until the end!", + "score": 8 + }, + { + "title": "Inspiring and Uplifting", + "body": "This movie is a feel-good gem that leaves you with a renewed sense of inspiration and hope. It touches your heart and reminds you of the power of dreams!", + "score": 9 + }, + { + "title": "Mind-Expanding Story", + "body": "This movie expands your mind and challenges your perspective. It delves into deep concepts and takes you on a journey of self-discovery. Prepare to be amazed!", + "score": 7 + }, + { + "title": "Action-Packed Thrills", + "body": "Hold onto your popcorn! This movie delivers non-stop action and thrilling sequences that will leave you breathless. Get ready for an adrenaline-fueled joyride!", + "score": 9 + }, + { + "title": "Incredible Performances", + "body": "The performances in this movie are top-notch! The talented cast brings their characters to life, delivering powerful and nuanced portrayals. A true acting masterclass!", + "score": 8 + }, + { + "title": "Disappointing Execution", + "body": "Despite promising elements, this movie fails to live up to expectations. The execution falls flat, leaving a sense of missed opportunities and unfulfilled potential.", + "score": 4 + }, + { + "title": "Lackluster Storytelling", + "body": "The story in this movie lacks depth and fails to engage. It feels clichéd and fails to offer any surprises or compelling narrative arcs. Disappointing overall.", + "score": 3 + }, + { + "title": "Underdeveloped Characters", + "body": "The characters in this movie lack depth and fail to leave a lasting impression. Their motivations and relationships feel superficial, leaving much to be desired.", + "score": 5 + }, + { + "title": "Visually Mediocre", + "body": "While the story may have potential, the visual execution leaves much to be desired. The cinematography and special effects fail to impress, resulting in a lackluster experience.", + "score": 4 + }, + { + "title": "Predictable and Formulaic", + "body": "This movie follows a predictable formula, leaving little room for surprises. The plot unfolds exactly as expected, offering little excitement or suspense.", + "score": 3 + }, + { + "title": "A Forgettable Experience", + "body": "Despite its promising premise, this movie fails to make a lasting impact. It lacks memorable moments and fails to leave a lasting impression. Easily forgettable.", + "score": 5 + }, + { + "title": "Engaging and Enthralling", + "body": "This movie grabs your attention from the very beginning and never lets go. The engaging storyline and captivating performances make it a must-watch!", + "score": 8 + }, + { + "title": "Deeply Moving and Emotional", + "body": "Be prepared for an emotional rollercoaster! This movie touches your heart and evokes a range of emotions. It's a powerful and moving cinematic experience!", + "score": 9 + }, + { + "title": "An Entertaining Delight", + "body": "This movie is pure entertainment! It's a delightful escape from reality, filled with fun and excitement. Sit back, relax, and enjoy the ride!", + "score": 8 + }, + { + "title": "Visually Mesmerizing", + "body": "Prepare to be mesmerized by the stunning visuals in this movie. The breathtaking imagery and artistic cinematography create a feast for the eyes!", + "score": 9 + }, + { + "title": "Suspenseful and Thrilling", + "body": "This movie keeps you on the edge of your seat with its suspenseful plot and heart-pounding moments. It's a gripping thrill ride you won't forget!", + "score": 8 + }, + { + "title": "An Inspiring Journey", + "body": "Get ready to be inspired! This movie takes you on a transformative journey, filled with life lessons, hope, and a renewed sense of purpose.", + "score": 9 + }, + { + "title": "Intellectually Stimulating", + "body": "This movie challenges your intellect and provokes thought. It raises important questions and offers unique perspectives. Prepare for a mentally engaging experience!", + "score": 7 + }, + { + "title": "Action-Packed and Exciting", + "body": "This movie is a thrilling adventure from start to finish. The action sequences are mind-blowing, and the excitement never lets up. Pure adrenaline rush!", + "score": 9 + }, + { + "title": "Outstanding Performances", + "body": "The actors in this movie deliver exceptional performances, bringing their characters to life with depth and authenticity. Truly remarkable!", + "score": 8 + }, + { + "title": "Disappointing Plot", + "body": "Unfortunately, the plot of this movie fails to captivate. It lacks originality and fails to offer any compelling twists or turns. Disappointing overall.", + "score": 4 + }, + { + "title": "Lacks Substance", + "body": "This movie may have flashy visuals, but it lacks substance. The story feels shallow and fails to resonate on a deeper level. A forgettable experience.", + "score": 3 + }, + { + "title": "Underwhelming Characters", + "body": "The characters in this movie are one-dimensional and fail to leave a lasting impression. Their development is lacking, resulting in a lackluster experience.", + "score": 5 + }, + { + "title": "Visually Unremarkable", + "body": "The visual presentation in this movie is unremarkable. It lacks creativity and fails to leave a lasting visual impact. A missed opportunity.", + "score": 4 + }, + { + "title": "Formulaic and Predictable", + "body": "This movie follows a familiar formula, leaving little room for surprises. The plot unfolds exactly as expected, resulting in a lack of excitement or intrigue.", + "score": 3 + }, + { + "title": "A Forgettable Journey", + "body": "Despite its initial promise, this movie fails to leave a lasting impact. It lacks memorable moments and fails to make a lasting impression. Easily forgettable.", + "score": 5 + }, + { + "title": "Compelling and Engrossing", + "body": "This movie hooks you from the beginning and keeps you engaged throughout. The captivating story and stellar performances make it a must-watch!", + "score": 8 + }, + { + "title": "A Rollercoaster of Emotions", + "body": "Get ready to laugh, cry, and everything in between! This movie tugs at your heartstrings and takes you on an emotional rollercoaster. Prepare for a truly moving experience!", + "score": 9 + }, + { + "title": "Pure Fun and Entertainment", + "body": "This movie is pure entertainment! It's a fun-filled adventure that will keep you entertained from start to finish. Sit back, relax, and enjoy the show!", + "score": 8 + }, + { + "title": "A Visual Masterpiece", + "body": "Prepare to be visually amazed! This movie boasts breathtaking visuals, stunning cinematography, and a visual style that will leave you in awe.", + "score": 9 + }, + { + "title": "Thrilling and Suspenseful", + "body": "This movie will keep you on the edge of your seat with its thrilling plot and suspenseful twists. It's a gripping cinematic experience you won't want to miss!", + "score": 8 + }, + { + "title": "An Inspiring and Motivational Tale", + "body": "This movie inspires you to chase your dreams and overcome obstacles. It's a motivational tale that leaves you feeling empowered and ready to take on the world!", + "score": 9 + }, + { + "title": "Intellectually Engaging", + "body": "Prepare to have your mind stimulated! This movie challenges your intellect with its thought-provoking concepts and complex narrative. A must-watch for thinkers!", + "score": 7 + }, + { + "title": "Action-Packed and Thrilling", + "body": "Hold onto your seats! This movie delivers high-octane action and thrilling sequences that will keep you on the edge of your seat. A pulse-pounding experience!", + "score": 9 + }, + { + "title": "Exceptional Acting", + "body": "The performances in this movie are truly remarkable. The cast delivers nuanced and powerful performances that elevate the storytelling. Bravo!", + "score": 8 + }, + { + "title": "Disappointing Execution", + "body": "Despite its promising premise, this movie fails to deliver. The execution falls flat, leaving a sense of missed opportunities and unfulfilled potential.", + "score": 4 + }, + { + "title": "Lackluster Storytelling", + "body": "The storytelling in this movie lacks depth and fails to engage. It feels clichéd and fails to offer any surprises or compelling narrative arcs. Disappointing overall.", + "score": 3 + }, + { + "title": "Underdeveloped Characters", + "body": "The characters in this movie lack depth and fail to leave a lasting impression. Their motivations and relationships feel superficial, leaving much to be desired.", + "score": 5 + }, + { + "title": "Visually Mediocre", + "body": "While the story may have potential, the visual execution leaves much to be desired. The cinematography and special effects fail to impress, resulting in a lackluster experience.", + "score": 4 + }, + { + "title": "Predictable and Formulaic", + "body": "This movie follows a predictable formula, leaving little room for surprises. The plot unfolds exactly as expected, offering little excitement or suspense.", + "score": 3 + }, + { + "title": "A Forgettable Experience", + "body": "Despite its promising premise, this movie fails to make a lasting impact. It lacks memorable moments and fails to leave a lasting impression. Easily forgettable.", + "score": 5 + }, + { + "title": "Engaging and Enthralling", + "body": "This movie grabs your attention from the very beginning and never lets go. The engaging storyline and captivating performances make it a must-watch!", + "score": 8 + }, + { + "title": "Deeply Moving and Emotional", + "body": "Be prepared for an emotional rollercoaster! This movie touches your heart and evokes a range of emotions. It's a powerful and moving cinematic experience!", + "score": 9 + }, + { + "title": "An Entertaining Delight", + "body": "This movie is pure entertainment! It's a delightful escape from reality, filled with fun and excitement. Sit back, relax, and enjoy the ride!", + "score": 8 + }, + { + "title": "Visually Mesmerizing", + "body": "Prepare to be mesmerized by the stunning visuals in this movie. The breathtaking imagery and artistic cinematography create a feast for the eyes!", + "score": 9 + }, + { + "title": "Suspenseful and Thrilling", + "body": "This movie keeps you on the edge of your seat with its suspenseful plot and heart-pounding moments. It's a gripping thrill ride you won't forget!", + "score": 8 + }, + { + "title": "An Inspiring Journey", + "body": "Get ready to be inspired! This movie takes you on a transformative journey, filled with life lessons, hope, and a renewed sense of purpose.", + "score": 9 + }, + { + "title": "Intellectually Stimulating", + "body": "This movie challenges your intellect and provokes thought. It raises important questions and offers unique perspectives. Prepare for a mentally engaging experience!", + "score": 7 + }, + { + "title": "Action-Packed and Exciting", + "body": "This movie is a thrilling adventure from start to finish. The action sequences are mind-blowing, and the excitement never lets up. Pure adrenaline rush!", + "score": 9 + }, + { + "title": "Outstanding Performances", + "body": "The actors in this movie deliver exceptional performances, bringing their characters to life with depth and authenticity. Truly remarkable!", + "score": 8 + }, + { + "title": "Disappointing Plot", + "body": "Unfortunately, the plot of this movie fails to captivate. It lacks originality and fails to offer any compelling twists or turns. Disappointing overall.", + "score": 4 + }, + { + "title": "Lacks Substance", + "body": "This movie may have flashy visuals, but it lacks substance. The story feels shallow and fails to resonate on a deeper level. A forgettable experience.", + "score": 3 + }, + { + "title": "Underwhelming Characters", + "body": "The characters in this movie are one-dimensional and fail to leave a lasting impression. Their development is lacking, resulting in a lackluster experience.", + "score": 5 + }, + { + "title": "Visually Unremarkable", + "body": "The visual presentation in this movie is unremarkable. It lacks creativity and fails to leave a lasting visual impact. A missed opportunity.", + "score": 4 + }, + { + "title": "Formulaic and Predictable", + "body": "This movie follows a familiar formula, leaving little room for surprises. The plot unfolds exactly as expected, resulting in a lack of excitement or intrigue.", + "score": 3 + }, + { + "title": "A Forgettable Journey", + "body": "Despite its initial promise, this movie fails to leave a lasting impact. It lacks memorable moments and fails to make a lasting impression. Easily forgettable.", + "score": 5 + }, + { + "title": "Compelling and Engrossing", + "body": "This movie hooks you from the beginning and keeps you engaged throughout. The captivating story and stellar performances make it a must-watch!", + "score": 8 + }, + { + "title": "A Rollercoaster of Emotions", + "body": "Get ready to laugh, cry, and everything in between! This movie tugs at your heartstrings and takes you on an emotional rollercoaster. Prepare for a truly moving experience!", + "score": 9 + }, + { + "title": "Pure Fun and Entertainment", + "body": "This movie is pure entertainment! It's a fun-filled adventure that will keep you entertained from start to finish. Sit back, relax, and enjoy the show!", + "score": 8 + }, + { + "title": "A Visual Masterpiece", + "body": "Prepare to be visually amazed! This movie boasts breathtaking visuals, stunning cinematography, and a visual style that will leave you in awe.", + "score": 9 + }, + { + "title": "Thrilling and Suspenseful", + "body": "This movie will keep you on the edge of your seat with its thrilling plot and suspenseful twists. It's a gripping cinematic experience you won't want to miss!", + "score": 8 + }, + { + "title": "An Inspiring and Motivational Tale", + "body": "This movie inspires you to chase your dreams and overcome obstacles. It's a motivational tale that leaves you feeling empowered and ready to take on the world!", + "score": 9 + }, + { + "title": "Intellectually Engaging", + "body": "Prepare to have your mind stimulated! This movie challenges your intellect with its thought-provoking concepts and complex narrative. A must-watch for thinkers!", + "score": 7 + }, + { + "title": "Action-Packed and Thrilling", + "body": "Hold onto your seats! This movie delivers high-octane action and thrilling sequences that will keep you on the edge of your seat. A pulse-pounding experience!", + "score": 9 + }, + { + "title": "Exceptional Acting", + "body": "The performances in this movie are truly remarkable. The cast delivers nuanced and powerful performances that elevate the storytelling. Bravo!", + "score": 8 + }, + { + "title": "Disappointing Execution", + "body": "Despite its promising premise, this movie fails to deliver. The execution falls flat, leaving a sense of missed opportunities and unfulfilled potential.", + "score": 4 + }, + { + "title": "Lackluster Storytelling", + "body": "The storytelling in this movie lacks depth and fails to engage. It feels clichéd and fails to offer any surprises or compelling narrative arcs. Disappointing overall.", + "score": 3 + }, + { + "title": "Underdeveloped Characters", + "body": "The characters in this movie lack depth and fail to leave a lasting impression. Their motivations and relationships feel superficial, leaving much to be desired.", + "score": 5 + }, + { + "title": "Visually Mediocre", + "body": "While the story may have potential, the visual execution leaves much to be desired. The cinematography and special effects fail to impress, resulting in a lackluster experience.", + "score": 4 + }, + { + "title": "Predictable and Formulaic", + "body": "This movie follows a predictable formula, leaving little room for surprises. The plot unfolds exactly as expected, offering little excitement or suspense.", + "score": 3 + }, + { + "title": "A Forgettable Experience", + "body": "Despite its promising premise, this movie fails to make a lasting impact. It lacks memorable moments and fails to leave a lasting impression. Easily forgettable.", + "score": 5 + }, + { + "title": "Engaging and Enthralling", + "body": "This movie grabs your attention from the very beginning and never lets go. The engaging storyline and captivating performances make it a must-watch!", + "score": 8 + }, + { + "title": "Deeply Moving and Emotional", + "body": "Be prepared for an emotional rollercoaster! This movie touches your heart and evokes a range of emotions. It's a powerful and moving cinematic experience!", + "score": 9 + }, + { + "title": "An Entertaining Delight", + "body": "This movie is pure entertainment! It's a delightful escape from reality, filled with fun and excitement. Sit back, relax, and enjoy the ride!", + "score": 8 + }, + { + "title": "Visually Mesmerizing", + "body": "Prepare to be mesmerized by the stunning visuals in this movie. The breathtaking imagery and artistic cinematography create a feast for the eyes!", + "score": 9 + }, + { + "title": "Suspenseful and Thrilling", + "body": "This movie keeps you on the edge of your seat with its suspenseful plot and heart-pounding moments. It's a gripping thrill ride you won't forget!", + "score": 8 + }, + { + "title": "An Inspiring Journey", + "body": "Get ready to be inspired! This movie takes you on a transformative journey, filled with life lessons, hope, and a renewed sense of purpose.", + "score": 9 + }, + { + "title": "Intellectually Stimulating", + "body": "This movie challenges your intellect and provokes thought. It raises important questions and offers unique perspectives. Prepare for a mentally engaging experience!", + "score": 7 + }, + { + "title": "Action-Packed and Exciting", + "body": "This movie is a thrilling adventure from start to finish. The action sequences are mind-blowing, and the excitement never lets up. Pure adrenaline rush!", + "score": 9 + }, + { + "title": "Outstanding Performances", + "body": "The actors in this movie deliver exceptional performances, bringing their characters to life with depth and authenticity. Truly remarkable!", + "score": 8 + }, + { + "title": "Disappointing Plot", + "body": "Unfortunately, the plot of this movie fails to captivate. It lacks originality and fails to offer any compelling twists or turns. Disappointing overall.", + "score": 4 + }, + { + "title": "Lacks Substance", + "body": "This movie may have flashy visuals, but it lacks substance. The story feels shallow and fails to resonate on a deeper level. A forgettable experience.", + "score": 3 + }, + { + "title": "Underwhelming Characters", + "body": "The characters in this movie are one-dimensional and fail to leave a lasting impression. Their development is lacking, resulting in a lackluster experience.", + "score": 5 + }, + { + "title": "Visually Unremarkable", + "body": "The visual presentation in this movie is unremarkable. It lacks creativity and fails to leave a lasting visual impact. A missed opportunity.", + "score": 4 + }, + { + "title": "Formulaic and Predictable", + "body": "This movie follows a familiar formula, leaving little room for surprises. The plot unfolds exactly as expected, resulting in a lack of excitement or intrigue.", + "score": 3 + }, + { + "title": "A Forgettable Journey", + "body": "Despite its initial promise, this movie fails to leave a lasting impact. It lacks memorable moments and fails to make a lasting impression. Easily forgettable.", + "score": 5 + } +] + diff --git a/app/Actions/Demo/demo-series-comments.json b/app/Actions/Demo/demo-series-comments.json new file mode 100755 index 0000000..bd27ac6 --- /dev/null +++ b/app/Actions/Demo/demo-series-comments.json @@ -0,0 +1,329 @@ +[ + { + "comment": "OMG, this series is mind-blowing! I can't stop watching!" + }, + { + "comment": "Just binge-watched this series and I'm hooked. It's so addictive!" + }, + { + "comment": "The storyline in this series is insane. So many twists and turns!" + }, + { + "comment": "The cast in this series is amazing. Each actor brings something unique!" + }, + { + "comment": "This series had me on the edge of my seat the whole time. So suspenseful!" + }, + { + "comment": "The chemistry between the characters in this series is off the charts. Love it!" + }, + { + "comment": "Laughed my heart out watching this comedy series. It's hilarious!" + }, + { + "comment": "The drama in this series is so intense. It keeps me wanting more!" + }, + { + "comment": "Just finished the latest season of this series. Mind officially blown!" + }, + { + "comment": "The soundtrack in this series is fire. Can't get enough of it!" + }, + { + "comment": "This series is binge-worthy. I can't stop clicking 'Next Episode'!" + }, + { + "comment": "The character development in this series is amazing. I feel so connected to them!" + }, + { + "comment": "This series has me emotionally invested. I laugh, I cry, I feel it all!" + }, + { + "comment": "The cliffhangers in this series are killing me. I need to know what happens next!" + }, + { + "comment": "The acting in this series is top-notch. Such talent on display!" + }, + { + "comment": "This series keeps me guessing at every turn. Never a dull moment!" + }, + { + "comment": "The relationships in this series are so compelling. I'm rooting for them!" + }, + { + "comment": "This series is pure entertainment. It's my guilty pleasure!" + }, + { + "comment": "The writing in this series is incredible. So many quotable lines!" + }, + { + "comment": "This series has a unique concept. Refreshing and original!" + }, + { + "comment": "The suspense in this series is killing me. Can't wait for the next episode!" + }, + { + "comment": "The chemistry between the cast members is amazing. They're like a family!" + }, + { + "comment": "This series has a great mix of genres. There's something for everyone!" + }, + { + "comment": "The plot twists in this series are mind-blowing. I never saw them coming!" + }, + { + "comment": "The performances in this series are outstanding. Each actor shines!" + }, + { + "comment": "This series is addictive. I'm obsessed with the characters and their stories!" + }, + { + "comment": "The humor in this series is on point. I can't stop laughing!" + }, + { + "comment": "This series keeps me on the edge of my seat. So many unexpected surprises!" + }, + { + "comment": "The soundtrack in this series is a vibe. It sets the perfect mood!" + }, + { + "comment": "This series is a rollercoaster of emotions. I'm hooked!" + }, + { + "comment": "The world-building in this series is impressive. So immersive!" + }, + { + "comment": "This series has strong female characters. They're empowering and inspiring!" + }, + { + "comment": "The cinematography in this series is stunning. Every shot is beautiful!" + }, + { + "comment": "This series has incredible storytelling. I'm captivated!" + }, + { + "comment": "The performances in this series are incredible. Each actor brings depth and nuance!" + }, + { + "comment": "This series is an emotional rollercoaster. It tugs at my heartstrings!" + }, + { + "comment": "The suspense in this series is intense. I can't stop watching!" + }, + { + "comment": "This series has a diverse and inclusive cast. Representation matters!" + }, + { + "comment": "The writing in this series is superb. It keeps me engaged!" + }, + { + "comment": "This series is my latest obsession. I can't get enough of it!" + }, + { + "comment": "The chemistry between the actors in this series is fire. They have amazing on-screen dynamics!" + }, + { + "comment": "This series is a rollercoaster ride of emotions. It takes me on a journey!" + }, + { + "comment": "The world-building in this series is phenomenal. It feels so real!" + }, + { + "comment": "This series tackles important social issues. It's thought-provoking!" + }, + { + "comment": "The cinematography in this series is stunning. Each frame is visually captivating!" + }, + { + "comment": "This series has a strong ensemble cast. Each actor brings something special!" + }, + { + "comment": "The plot twists in this series keep me on my toes. Never a dull moment!" + }, + { + "comment": "This series has me emotionally invested. I feel like I'm part of their world!" + }, + { + "comment": "The character dynamics in this series are so well-developed. I love their interactions!" + }, + { + "comment": "This series has so many memorable moments. It leaves a lasting impression!" + }, + { + "comment": "The performances in this series are exceptional. Each actor brings depth to their character!" + }, + { + "comment": "This series is addictive. I can't stop watching episode after episode!" + }, + { + "comment": "The humor in this series is hilarious. It never fails to make me laugh!" + }, + { + "comment": "This series keeps me on the edge of my seat. It's a wild ride!" + }, + { + "comment": "The soundtrack in this series is incredible. It enhances the storytelling!" + }, + { + "comment": "This series is an emotional rollercoaster. It makes me laugh and cry!" + }, + { + "comment": "The attention to detail in this series is impressive. It's a visual feast!" + }, + { + "comment": "This series has a diverse cast with amazing performances. It's inclusive and representative!" + }, + { + "comment": "The writing in this series is brilliant. The dialogue is sharp and engaging!" + }, + { + "comment": "This series has become my new obsession. I can't get enough of it!" + }, + { + "comment": "The chemistry between the actors in this series is electric. Their performances are captivating!" + }, + { + "comment": "This series is a whirlwind of emotions. It has me invested in every character!" + }, + { + "comment": "The world-building in this series is rich and immersive. It feels like a whole new universe!" + }, + { + "comment": "This series tackles relevant and timely themes. It's thought-provoking and impactful!" + }, + { + "comment": "The cinematography in this series is stunning. It adds another layer of beauty to the storytelling!" + }, + { + "comment": "This series has an incredible ensemble cast. The chemistry between the actors is incredible!" + }, + { + "comment": "The plot twists in this series are mind-bending. It keeps me guessing all the time!" + }, + { + "comment": "This series has a strong emotional core. It touches my heart and leaves a lasting impact!" + }, + { + "comment": "The character development in this series is exceptional. I love seeing their growth!" + }, + { + "comment": "This series is so addictive. I can't stop watching. It's my latest obsession!" + }, + { + "comment": "The humor in this series is top-notch. It's a perfect balance of comedy and heart!" + }, + { + "comment": "This series is full of surprises. It keeps me guessing and on the edge of my seat!" + }, + { + "comment": "The soundtrack in this series is phenomenal. It adds so much to the atmosphere!" + }, + { + "comment": "This series is an emotional rollercoaster. It takes me through highs and lows!" + }, + { + "comment": "The attention to detail in this series is incredible. The production value is top-notch!" + }, + { + "comment": "This series has a diverse cast that brings authenticity and representation. It's amazing!" + }, + { + "comment": "The writing in this series is exceptional. It keeps me engaged and invested in the story!" + }, + { + "comment": "This series has become my new addiction. I can't get enough of it!" + }, + { + "comment": "The chemistry between the actors in this series is off the charts. They have incredible synergy!" + }, + { + "comment": "This series is an emotional rollercoaster. It makes me feel all the feels!" + }, + { + "comment": "The world-building in this series is immersive and captivating. It feels like a whole new reality!" + }, + { + "comment": "This series tackles important issues with sensitivity and depth. It's thought-provoking!" + }, + { + "comment": "The cinematography in this series is stunning. It's visually breathtaking!" + }, + { + "comment": "This series has an amazing ensemble cast. Each actor brings their A-game!" + }, + { + "comment": "The plot twists in this series are mind-blowing. It keeps me on the edge of my seat!" + }, + { + "comment": "This series has characters I can't help but root for. I'm emotionally invested!" + }, + { + "comment": "The character dynamics in this series are so well-written. The relationships feel real!" + }, + { + "comment": "This series has so many unforgettable moments. It leaves a lasting impact!" + }, + { + "comment": "The performances in this series are outstanding. Each actor brings depth to their role!" + }, + { + "comment": "Just watched the latest episode of this series and it's mind-blowing! Can't wait for more!" + }, + { + "comment": "This series keeps getting better and better with each episode. I'm hooked!" + }, + { + "comment": "The latest episode of this series left me speechless. It's a game-changer!" + }, + { + "comment": "The character development in this series is outstanding. I love seeing them grow!" + }, + { + "comment": "The latest episode of this series had me on the edge of my seat. So thrilling!" + }, + { + "comment": "I just can't get enough of this series. Each episode leaves me wanting more!" + }, + { + "comment": "The latest episode of this series had me laughing out loud. It's so funny!" + }, + { + "comment": "This series knows how to keep me invested. The latest episode was so captivating!" + }, + { + "comment": "The latest episode of this series had so many unexpected twists. It's unpredictable!" + }, + { + "comment": "I'm completely obsessed with this series. The latest episode blew my mind!" + }, + { + "comment": "The latest episode of this series is visually stunning. The production value is incredible!" + }, + { + "comment": "This series just keeps raising the bar. The latest episode was exceptional!" + }, + { + "comment": "The chemistry between the characters in this series is off the charts. I love their dynamics!" + }, + { + "comment": "The latest episode of this series tugged at my heartstrings. It's so emotional!" + }, + { + "comment": "I'm always left wanting more after each episode of this series. It's addictive!" + }, + { + "comment": "The latest episode of this series had me at the edge of my seat. So intense!" + }, + { + "comment": "This series continues to surprise me. The latest episode had an unexpected twist!" + }, + { + "comment": "The latest episode of this series had me completely hooked. It's so well-written!" + }, + { + "comment": "I just can't get enough of this series. The latest episode was phenomenal!" + }, + { + "comment": "The latest episode of this series had me on an emotional rollercoaster. It's powerful!" + } +] diff --git a/app/Actions/Demo/demo-series-reviews.json b/app/Actions/Demo/demo-series-reviews.json new file mode 100755 index 0000000..125c73b --- /dev/null +++ b/app/Actions/Demo/demo-series-reviews.json @@ -0,0 +1,507 @@ +[ + { + "title": "Binge-Worthy Series!", + "body": "I couldn't stop watching this series! Each episode left me wanting more. The storyline and characters are captivating. Highly recommended!", + "score": 9 + }, + { + "title": "Addictive and Engaging", + "body": "This series had me hooked from the very first episode. The plot twists and character development kept me invested throughout. Can't wait for the next season!", + "score": 8 + }, + { + "title": "Emotional Rollercoaster", + "body": "This series took me on an emotional journey. I laughed, I cried, and I felt deeply connected to the characters. It's a must-watch!", + "score": 7 + }, + { + "title": "Hilarious and Heartwarming", + "body": "I couldn't stop laughing while watching this series. The humor is spot-on, and the heartwarming moments tug at your heartstrings. A perfect balance!", + "score": 8 + }, + { + "title": "Intriguing and Mysterious", + "body": "This series kept me guessing until the very end. The mystery and suspense had me on the edge of my seat. A thrilling ride!", + "score": 9 + }, + { + "title": "Well-Written and Gripping", + "body": "The writing in this series is exceptional. The dialogue is sharp, and the storylines are compelling. It's a masterclass in storytelling!", + "score": 8 + }, + { + "title": "Powerful and Thought-Provoking", + "body": "This series tackles important social issues and provokes thought. It sheds light on topics that need to be discussed. A truly impactful watch!", + "score": 7 + }, + { + "title": "Addictive Plot Twists", + "body": "Just when I thought I had it figured out, this series hit me with unexpected plot twists. It kept me engaged and eagerly anticipating the next episode!", + "score": 8 + }, + { + "title": "Relatable and Authentic", + "body": "The characters in this series feel like real people. I could relate to their struggles and victories. It's a genuine and authentic portrayal!", + "score": 9 + }, + { + "title": "Mind-Blowing Performances", + "body": "The cast in this series delivers outstanding performances. Their talent and chemistry bring the characters to life. Bravo!", + "score": 8 + }, + { + "title": "Lacks Substance", + "body": "Unfortunately, this series lacks depth. The characters are underdeveloped, and the plot feels weak. It didn't leave a lasting impression.", + "score": 4 + }, + { + "title": "Disappointing Execution", + "body": "I had high expectations for this series, but it fell short. The pacing was off, and the storytelling felt disjointed. A missed opportunity.", + "score": 3 + }, + { + "title": "Unengaging Storylines", + "body": "The series failed to captivate me. The storylines felt uninteresting, and the episodes dragged on. It didn't hold my attention.", + "score": 5 + }, + { + "title": "Lackluster Character Development", + "body": "The characters in this series lacked depth and growth. Their arcs felt stagnant, and I struggled to connect with them.", + "score": 4 + }, + { + "title": "Visually Stunning", + "body": "This series is a visual treat. The cinematography and production design are top-notch. It's a feast for the eyes!", + "score": 9 + }, + { + "title": "Addictive and Suspenseful", + "body": "I couldn't stop watching this series. Each episode ended with a cliffhanger that left me eagerly waiting for the next. So suspenseful!", + "score": 8 + }, + { + "title": "Heartwarming and Inspiring", + "body": "This series touched my heart. It's uplifting, inspiring, and reminds us of the power of friendship and resilience. A feel-good watch!", + "score": 9 + }, + { + "title": "Thought-Provoking and Mind-Bending", + "body": "This series messes with your mind in the best way possible. It raises intriguing questions and challenges your perception of reality.", + "score": 7 + }, + { + "title": "A Rollercoaster of Emotions", + "body": "This series had me laughing one moment and in tears the next. It's an emotional rollercoaster that leaves a lasting impact.", + "score": 8 + }, + { + "title": "Captivating Ensemble Cast", + "body": "The chemistry among the cast members is electric. They bring the characters to life with such authenticity and make the series truly engaging.", + "score": 9 + }, + { + "title": "Disappointing Plot Twists", + "body": "The series relied too heavily on predictable plot twists. It left me underwhelmed and craving for more surprising twists and turns.", + "score": 4 + }, + { + "title": "Lacks Coherence", + "body": "The series felt disjointed and lacked a cohesive narrative. The storylines didn't seamlessly come together, leaving me confused.", + "score": 3 + }, + { + "title": "Underdeveloped Supporting Characters", + "body": "While the main characters shined, the supporting cast felt underutilized. Their arcs were shallow and left much to be desired.", + "score": 5 + }, + { + "title": "Visually Underwhelming", + "body": "The series didn't make good use of its visual potential. The cinematography and production design felt lackluster and uninspired.", + "score": 4 + }, + { + "title": "Predictable Storylines", + "body": "The series followed familiar tropes and lacked originality. The storylines unfolded exactly as expected, offering little surprises.", + "score": 3 + }, + { + "title": "A Forgettable Experience", + "body": "Despite its initial promise, this series failed to make a lasting impression. It lacked memorable moments and failed to leave a lasting impact.", + "score": 5 + }, + { + "title": "Compelling and Addictive", + "body": "This series had me hooked from the first episode. The characters and their complex relationships kept me invested throughout. A must-watch!", + "score": 8 + }, + { + "title": "Gripping and Intense", + "body": "The series is a thrilling ride from start to finish. The suspense and tension never let up. I couldn't look away!", + "score": 9 + }, + { + "title": "Relatable and Authentic", + "body": "The characters in this series feel like real people. I could relate to their struggles and triumphs. It's a genuine and authentic portrayal!", + "score": 8 + }, + { + "title": "Brilliant Ensemble Cast", + "body": "The cast in this series is phenomenal. Each actor brings their A-game, creating a dynamic and captivating ensemble. Brilliant performances!", + "score": 9 + }, + { + "title": "Underwhelming Plot", + "body": "The series failed to deliver an engaging and cohesive storyline. The plot felt disjointed and left me wanting more depth.", + "score": 4 + }, + { + "title": "Slow-Paced and Boring", + "body": "The series lacked excitement and dragged on. The slow pace made it difficult to stay engaged. It didn't hold my interest.", + "score": 3 + }, + { + "title": "Weak Supporting Characters", + "body": "While the main characters were well-developed, the supporting cast felt underdeveloped and served little purpose in the series.", + "score": 5 + }, + { + "title": "Visually Average", + "body": "The series didn't stand out visually. The cinematography and production design were mediocre, lacking creativity and flair.", + "score": 4 + }, + { + "title": "Lack of Originality", + "body": "The series felt derivative and failed to bring anything new to the table. It lacked fresh ideas and original storytelling.", + "score": 3 + }, + { + "title": "Easily Forgettable", + "body": "Despite its initial intrigue, this series didn't leave a lasting impact. It lacked memorable moments and failed to make a lasting impression.", + "score": 5 + }, + { + "title": "Compelling and Captivating", + "body": "This series had me hooked from the first episode. The intricate plot and complex characters kept me on the edge of my seat. A must-watch!", + "score": 8 + }, + { + "title": "Thrilling and Heart-Pounding", + "body": "This series had me on the edge of my seat. The suspense and intense moments kept me glued to the screen. So exhilarating!", + "score": 9 + }, + { + "title": "Authentic Character Development", + "body": "The series beautifully portrayed the growth and development of its characters. I felt invested in their journeys. Truly compelling!", + "score": 8 + }, + { + "title": "Ensemble Cast Chemistry", + "body": "The chemistry among the cast members is electric. Their interactions and dynamics add depth and richness to the series. Fantastic ensemble!", + "score": 9 + }, + { + "title": "Weak Plot Progression", + "body": "The series struggled with pacing and failed to progress the plot effectively. It felt stagnant and lacked momentum.", + "score": 4 + }, + { + "title": "Lacks Excitement", + "body": "The series failed to deliver thrilling moments or exciting plot developments. It left me wanting more action and intensity.", + "score": 3 + }, + { + "title": "Underutilized Supporting Characters", + "body": "While the main characters shined, the supporting cast felt underused and lacked substantial storylines. Their potential wasn't fully explored.", + "score": 5 + }, + { + "title": "Visually Unimpressive", + "body": "The series didn't utilize its visual elements to their full potential. The cinematography and production design were uninspiring.", + "score": 4 + }, + { + "title": "Unoriginal Storylines", + "body": "The series relied on familiar tropes and predictable plotlines. It lacked innovation and failed to offer any surprises.", + "score": 3 + }, + { + "title": "Easily Forgettable", + "body": "Despite its initial promise, this series didn't leave a lasting impression. It lacked memorable moments and failed to make a lasting impact.", + "score": 5 + }, + { + "title": "Engrossing and Addictive", + "body": "This series had me captivated from the first episode. The storytelling and character arcs kept me invested throughout. A must-watch!", + "score": 8 + }, + { + "title": "Nail-Biting and Suspenseful", + "body": "The series had me on the edge of my seat. The suspense and tension were palpable. I couldn't look away!", + "score": 9 + }, + { + "title": "Well-Developed Characters", + "body": "The series excelled in character development. Each character had depth and their own compelling storylines. Truly engaging!", + "score": 8 + }, + { + "title": "Chemistry-Driven Ensemble", + "body": "The cast's chemistry is electric. Their interactions and relationships bring an extra layer of authenticity to the series. Outstanding ensemble!", + "score": 9 + }, + { + "title": "Weak Plot Twists", + "body": "The series failed to deliver impactful plot twists. The twists felt predictable and lacked the wow factor. Disappointing.", + "score": 4 + }, + { + "title": "Lacks Momentum", + "body": "The series struggled to maintain momentum. It felt slow and didn't build enough excitement or suspense.", + "score": 3 + }, + { + "title": "Underdeveloped Supporting Cast", + "body": "While the main characters shined, the supporting cast felt underutilized and lacked meaningful character arcs. A missed opportunity.", + "score": 5 + }, + { + "title": "Visually Unremarkable", + "body": "The series didn't stand out visually. The cinematography and production design were average, lacking innovation and creativity.", + "score": 4 + }, + { + "title": "Predictable and Clichéd", + "body": "The series followed predictable storylines and clichéd tropes. It failed to bring anything fresh or original to the table.", + "score": 3 + }, + { + "title": "Easily Forgettable", + "body": "Despite its initial intrigue, this series didn't leave a lasting impact. It lacked memorable moments and failed to make a lasting impression.", + "score": 5 + }, + { + "title": "Compelling and Addictive", + "body": "This series had me hooked from the first episode. The intricate plot and complex characters kept me on the edge of my seat. A must-watch!", + "score": 8 + }, + { + "title": "Thrilling and Heart-Pounding", + "body": "This series had me on the edge of my seat. The suspense and intense moments kept me glued to the screen. So exhilarating!", + "score": 9 + }, + { + "title": "Authentic Character Development", + "body": "The series beautifully portrayed the growth and development of its characters. I felt invested in their journeys. Truly compelling!", + "score": 8 + }, + { + "title": "Ensemble Cast Chemistry", + "body": "The chemistry among the cast members is electric. Their interactions and dynamics add depth and richness to the series. Fantastic ensemble!", + "score": 9 + }, + { + "title": "Weak Plot Progression", + "body": "The series struggled with pacing and failed to progress the plot effectively. It felt stagnant and lacked momentum.", + "score": 4 + }, + { + "title": "Lacks Excitement", + "body": "The series failed to deliver thrilling moments or exciting plot developments. It left me wanting more action and intensity.", + "score": 3 + }, + { + "title": "Underutilized Supporting Characters", + "body": "While the main characters shined, the supporting cast felt underused and lacked substantial storylines. Their potential wasn't fully explored.", + "score": 5 + }, + { + "title": "Visually Unimpressive", + "body": "The series didn't utilize its visual elements to their full potential. The cinematography and production design were uninspiring.", + "score": 4 + }, + { + "title": "Unoriginal Storylines", + "body": "The series relied on familiar tropes and predictable plotlines. It lacked innovation and failed to offer any surprises.", + "score": 3 + }, + { + "title": "Easily Forgettable", + "body": "Despite its initial promise, this series didn't leave a lasting impression. It lacked memorable moments and failed to make a lasting impact.", + "score": 5 + }, + { + "title": "Compelling and Captivating", + "body": "This series had me hooked from the first episode. The intricate plot and complex characters kept me on the edge of my seat. A must-watch!", + "score": 8 + }, + { + "title": "Thrilling and Suspenseful", + "body": "This series kept me on the edge of my seat. The suspense and tension were palpable. I couldn't look away!", + "score": 9 + }, + { + "title": "Engaging Character Dynamics", + "body": "The series excelled in showcasing the relationships and dynamics between the characters. Their interactions were captivating.", + "score": 8 + }, + { + "title": "Impressive Ensemble Cast", + "body": "The cast delivered outstanding performances, bringing depth and authenticity to their characters. A stellar ensemble!", + "score": 9 + }, + { + "title": "Weak Plot Development", + "body": "The series struggled to develop its plot effectively. The storylines felt disjointed and lacked coherence.", + "score": 4 + }, + { + "title": "Lackluster Excitement", + "body": "The series failed to deliver excitement or thrilling moments. It lacked the necessary suspense and intensity.", + "score": 3 + }, + { + "title": "Underdeveloped Supporting Cast", + "body": "While the main characters were well-developed, the supporting cast felt underutilized and lacked substantial storylines.", + "score": 5 + }, + { + "title": "Visually Mediocre", + "body": "The series didn't stand out visually. The cinematography and production design felt average and uninspired.", + "score": 4 + }, + { + "title": "Predictable and Stale", + "body": "The series followed predictable storylines and failed to offer any surprises. It lacked freshness and originality.", + "score": 3 + }, + { + "title": "Easily Forgettable", + "body": "Despite its initial intrigue, this series didn't leave a lasting impact. It lacked memorable moments and failed to make a lasting impression.", + "score": 5 + }, + { + "title": "Addictive and Riveting", + "body": "This series had me hooked from the first episode. The intriguing storyline and compelling characters kept me engaged throughout. Can't wait for the next season!", + "score": 8 + }, + { + "title": "Heartwarming and Uplifting", + "body": "The series touched my heart and left me feeling inspired. It's a beautiful portrayal of hope, love, and resilience. Highly recommended!", + "score": 9 + }, + { + "title": "Laugh-Out-Loud Funny", + "body": "This series had me in stitches from start to finish. The humor is clever and the comedic timing is spot-on. A perfect comedy series!", + "score": 8 + }, + { + "title": "Mind-Blowing Plot Twists", + "body": "Just when I thought I had it all figured out, this series delivered mind-blowing plot twists that left me in awe. It kept me guessing until the very end!", + "score": 9 + }, + { + "title": "Engaging and Addictive", + "body": "This series grabbed my attention and didn't let go. The fast-paced storytelling and gripping cliffhangers kept me eagerly anticipating the next episode.", + "score": 8 + }, + { + "title": "Thought-Provoking and Intelligent", + "body": "The series tackles complex themes and presents them with depth and intelligence. It sparks meaningful discussions and leaves a lasting impact.", + "score": 7 + }, + { + "title": "Unexpected and Surprising", + "body": "This series kept me on my toes with its unexpected plot developments and surprising character arcs. It defied my expectations in the best way!", + "score": 8 + }, + { + "title": "Gripping and Suspenseful", + "body": "The series had me on the edge of my seat. The tension and suspense were palpable, making it a thrilling and nail-biting experience.", + "score": 9 + }, + { + "title": "Immersive and Captivating", + "body": "This series transported me to another world. The immersive storytelling and rich world-building kept me fully engaged. A must-watch for fantasy fans!", + "score": 8 + }, + { + "title": "Powerful and Impactful", + "body": "This series packs an emotional punch. It explores important themes with depth and authenticity, leaving a lasting impact on the viewer.", + "score": 9 + }, + { + "title": "Addictive and Thrilling", + "body": "I couldn't stop watching this series. Each episode ended with a cliffhanger that left me craving for more. It's a pulse-pounding thrill ride!", + "score": 8 + }, + { + "title": "Compelling Character Dynamics", + "body": "The relationships between the characters are the heart of this series. The chemistry and complexity make it a truly engaging watch.", + "score": 9 + }, + { + "title": "Disappointing Plot Development", + "body": "The series failed to progress the plot effectively. It felt slow and lacked the necessary momentum to keep me fully invested.", + "score": 4 + }, + { + "title": "Underwhelming and Lackluster", + "body": "This series didn't live up to the hype. It lacked excitement and failed to deliver on its promising premise. Disappointing overall.", + "score": 3 + }, + { + "title": "Weak Characterization", + "body": "The characters in this series felt one-dimensional and lacked depth. Their arcs and motivations were underdeveloped and left me wanting more.", + "score": 5 + }, + { + "title": "Visually Stunning", + "body": "The series is a visual feast for the eyes. The stunning cinematography and production design create a mesmerizing and immersive experience.", + "score": 9 + }, + { + "title": "Emotionally Charged", + "body": "This series tugged at my heartstrings. The emotional depth and raw performances left me in tears. A deeply moving experience!", + "score": 8 + }, + { + "title": "Smart and Mind-Bending", + "body": "The series challenges your perception and keeps you guessing. It's a smartly crafted and mind-bending journey that will leave you in awe.", + "score": 9 + }, + { + "title": "Heartfelt and Genuine", + "body": "This series touched my heart with its authenticity and heartfelt storytelling. It's a genuine portrayal of human emotions and relationships.", + "score": 8 + }, + { + "title": "Disappointing Plot Twists", + "body": "The series relied on predictable and uninspired plot twists. It failed to surprise or engage me. The twists felt forced and lacking impact.", + "score": 4 + }, + { + "title": "Lacks Momentum and Pacing", + "body": "The series struggled with pacing issues and lacked the necessary momentum to keep me fully engaged. It felt slow and dragged on at times.", + "score": 3 + }, + { + "title": "Underdeveloped Supporting Characters", + "body": "While the main characters shined, the supporting cast felt underutilized and lacked substantial storylines. Their potential wasn't fully realized.", + "score": 5 + }, + { + "title": "Visually Mediocre", + "body": "The series didn't impress visually. The cinematography and production design felt average and didn't leave a lasting impact.", + "score": 4 + }, + { + "title": "Predictable and Formulaic", + "body": "The series followed a predictable formula, leaving little room for surprises. It felt formulaic and failed to offer originality.", + "score": 3 + }, + { + "title": "Easily Forgettable", + "body": "Despite its initial intrigue, this series failed to leave a lasting impression. It lacked memorable moments and failed to make a lasting impact.", + "score": 5 + } +] diff --git a/app/Actions/Demo/demo-users.json b/app/Actions/Demo/demo-users.json new file mode 100755 index 0000000..70afc8c --- /dev/null +++ b/app/Actions/Demo/demo-users.json @@ -0,0 +1,686 @@ +[ + { + "email": "john.doe@example.com", + "first_name": "John", + "last_name": "Doe", + "gender": "male" + }, + { + "email": "jane.smith@example.com", + "first_name": "Jane", + "last_name": "Smith", + "gender": "female" + }, + { + "email": "michael.johnson@example.com", + "first_name": "Michael", + "last_name": "Johnson", + "gender": "male" + }, + { + "email": "emily.williams@example.com", + "first_name": "Emily", + "last_name": "Williams", + "gender": "female" + }, + { + "email": "william.brown@example.com", + "first_name": "William", + "last_name": "Brown", + "gender": "male" + }, + { + "email": "sophia.jones@example.com", + "first_name": "Sophia", + "last_name": "Jones", + "gender": "female" + }, + { + "email": "jackson.taylor@example.com", + "first_name": "Jackson", + "last_name": "Taylor", + "gender": "male" + }, + { + "email": "olivia.anderson@example.com", + "first_name": "Olivia", + "last_name": "Anderson", + "gender": "female" + }, + { + "email": "matthew.johnson@example.com", + "first_name": "Matthew", + "last_name": "Johnson", + "gender": "male" + }, + { + "email": "emma.martin@example.com", + "first_name": "Emma", + "last_name": "Martin", + "gender": "female" + }, + { + "email": "liam.thompson@example.com", + "first_name": "Liam", + "last_name": "Thompson", + "gender": "male" + }, + { + "email": "ava.hernandez@example.com", + "first_name": "Ava", + "last_name": "Hernandez", + "gender": "female" + }, + { + "email": "noah.white@example.com", + "first_name": "Noah", + "last_name": "White", + "gender": "male" + }, + { + "email": "isabella.moore@example.com", + "first_name": "Isabella", + "last_name": "Moore", + "gender": "female" + }, + { + "email": "ethan.martin@example.com", + "first_name": "Ethan", + "last_name": "Martin", + "gender": "male" + }, + { + "email": "mia.lewis@example.com", + "first_name": "Mia", + "last_name": "Lewis", + "gender": "female" + }, + { + "email": "mason.hill@example.com", + "first_name": "Mason", + "last_name": "Hill", + "gender": "male" + }, + { + "email": "amelia.wilson@example.com", + "first_name": "Amelia", + "last_name": "Wilson", + "gender": "female" + }, + { + "email": "logan.clark@example.com", + "first_name": "Logan", + "last_name": "Clark", + "gender": "male" + }, + { + "email": "harper.robinson@example.com", + "first_name": "Harper", + "last_name": "Robinson", + "gender": "female" + }, + { + "email": "oliver.walker@example.com", + "first_name": "Oliver", + "last_name": "Walker", + "gender": "male" + }, + { + "email": "evelyn.cooper@example.com", + "first_name": "Evelyn", + "last_name": "Cooper", + "gender": "female" + }, + { + "email": "lucas.peterson@example.com", + "first_name": "Lucas", + "last_name": "Peterson", + "gender": "male" + }, + { + "email": "abigail.kelly@example.com", + "first_name": "Abigail", + "last_name": "Kelly", + "gender": "female" + }, + { + "email": "aiden.richardson@example.com", + "first_name": "Aiden", + "last_name": "Richardson", + "gender": "male" + }, + { + "email": "elizabeth.cook@example.com", + "first_name": "Elizabeth", + "last_name": "Cook", + "gender": "female" + }, + { + "email": "michael.ross@example.com", + "first_name": "Michael", + "last_name": "Ross", + "gender": "male" + }, + { + "email": "sofia.bennett@example.com", + "first_name": "Sofia", + "last_name": "Bennett", + "gender": "female" + }, + { + "email": "alexander.brooks@example.com", + "first_name": "Alexander", + "last_name": "Brooks", + "gender": "male" + }, + { + "email": "charlotte.bell@example.com", + "first_name": "Charlotte", + "last_name": "Bell", + "gender": "female" + }, + { + "email": "ethan.hall@example.com", + "first_name": "Ethan", + "last_name": "Hall", + "gender": "male" + }, + { + "email": "grace.perez@example.com", + "first_name": "Grace", + "last_name": "Perez", + "gender": "female" + }, + { + "email": "daniel.baker@example.com", + "first_name": "Daniel", + "last_name": "Baker", + "gender": "male" + }, + { + "email": "hannah.bailey@example.com", + "first_name": "Hannah", + "last_name": "Bailey", + "gender": "female" + }, + { + "email": "matthew.rogers@example.com", + "first_name": "Matthew", + "last_name": "Rogers", + "gender": "male" + }, + { + "email": "ella.gomez@example.com", + "first_name": "Ella", + "last_name": "Gomez", + "gender": "female" + }, + { + "email": "joseph.morris@example.com", + "first_name": "Joseph", + "last_name": "Morris", + "gender": "male" + }, + { + "email": "chloe.bailey@example.com", + "first_name": "Chloe", + "last_name": "Bailey", + "gender": "female" + }, + { + "email": "william.sanchez@example.com", + "first_name": "William", + "last_name": "Sanchez", + "gender": "male" + }, + { + "email": "zoey.harris@example.com", + "first_name": "Zoey", + "last_name": "Harris", + "gender": "female" + }, + { + "email": "christopher.foster@example.com", + "first_name": "Christopher", + "last_name": "Foster", + "gender": "male" + }, + { + "email": "aubrey.patterson@example.com", + "first_name": "Aubrey", + "last_name": "Patterson", + "gender": "female" + }, + { + "email": "ryan.bell@example.com", + "first_name": "Ryan", + "last_name": "Bell", + "gender": "male" + }, + { + "email": "avery.sullivan@example.com", + "first_name": "Avery", + "last_name": "Sullivan", + "gender": "female" + }, + { + "email": "noah.washington@example.com", + "first_name": "Noah", + "last_name": "Washington", + "gender": "male" + }, + { + "email": "addison.roberts@example.com", + "first_name": "Addison", + "last_name": "Roberts", + "gender": "female" + }, + { + "email": "david.hughes@example.com", + "first_name": "David", + "last_name": "Hughes", + "gender": "male" + }, + { + "email": "madison.murphy@example.com", + "first_name": "Madison", + "last_name": "Murphy", + "gender": "female" + }, + { + "email": "jacob.edwards@example.com", + "first_name": "Jacob", + "last_name": "Edwards", + "gender": "male" + }, + { + "email": "scarlett.watson@example.com", + "first_name": "Scarlett", + "last_name": "Watson", + "gender": "female" + }, + { + "email": "michael.russell@example.com", + "first_name": "Michael", + "last_name": "Russell", + "gender": "male" + }, + { + "email": "grace.hill@example.com", + "first_name": "Grace", + "last_name": "Hill", + "gender": "female" + }, + { + "email": "logan.mitchell@example.com", + "first_name": "Logan", + "last_name": "Mitchell", + "gender": "male" + }, + { + "email": "sophia.myers@example.com", + "first_name": "Sophia", + "last_name": "Myers", + "gender": "female" + }, + { + "email": "john.richardson@example.com", + "first_name": "John", + "last_name": "Richardson", + "gender": "male" + }, + { + "email": "abigail.wilson@example.com", + "first_name": "Abigail", + "last_name": "Wilson", + "gender": "female" + }, + { + "email": "william.sanders@example.com", + "first_name": "William", + "last_name": "Sanders", + "gender": "male" + }, + { + "email": "lily.howard@example.com", + "first_name": "Lily", + "last_name": "Howard", + "gender": "female" + }, + { + "email": "james.hall@example.com", + "first_name": "James", + "last_name": "Hall", + "gender": "male" + }, + { + "email": "sophia.gonzalez@example.com", + "first_name": "Sophia", + "last_name": "Gonzalez", + "gender": "female" + }, + { + "email": "joseph.russell@example.com", + "first_name": "Joseph", + "last_name": "Russell", + "gender": "male" + }, + { + "email": "mia.allen@example.com", + "first_name": "Mia", + "last_name": "Allen", + "gender": "female" + }, + { + "email": "alexander.peterson@example.com", + "first_name": "Alexander", + "last_name": "Peterson", + "gender": "male" + }, + { + "email": "emily.brooks@example.com", + "first_name": "Emily", + "last_name": "Brooks", + "gender": "female" + }, + { + "email": "daniel.stewart@example.com", + "first_name": "Daniel", + "last_name": "Stewart", + "gender": "male" + }, + { + "email": "chloe.bennett@example.com", + "first_name": "Chloe", + "last_name": "Bennett", + "gender": "female" + }, + { + "email": "lucas.rogers@example.com", + "first_name": "Lucas", + "last_name": "Rogers", + "gender": "male" + }, + { + "email": "ava.watson@example.com", + "first_name": "Ava", + "last_name": "Watson", + "gender": "female" + }, + { + "email": "mason.howard@example.com", + "first_name": "Mason", + "last_name": "Howard", + "gender": "male" + }, + { + "email": "zoey.gonzalez@example.com", + "first_name": "Zoey", + "last_name": "Gonzalez", + "gender": "female" + }, + { + "email": "andrew.wood@example.com", + "first_name": "Andrew", + "last_name": "Wood", + "gender": "male" + }, + { + "email": "grace.patterson@example.com", + "first_name": "Grace", + "last_name": "Patterson", + "gender": "female" + }, + { + "email": "william.perez@example.com", + "first_name": "William", + "last_name": "Perez", + "gender": "male" + }, + { + "email": "scarlett.bailey@example.com", + "first_name": "Scarlett", + "last_name": "Bailey", + "gender": "female" + }, + { + "email": "ryan.sanchez@example.com", + "first_name": "Ryan", + "last_name": "Sanchez", + "gender": "male" + }, + { + "email": "olivia.harris@example.com", + "first_name": "Olivia", + "last_name": "Harris", + "gender": "female" + }, + { + "email": "alexander.kelly@example.com", + "first_name": "Alexander", + "last_name": "Kelly", + "gender": "male" + }, + { + "email": "aubrey.richardson@example.com", + "first_name": "Aubrey", + "last_name": "Richardson", + "gender": "female" + }, + { + "email": "noah.cook@example.com", + "first_name": "Noah", + "last_name": "Cook", + "gender": "male" + }, + { + "email": "david.morris@example.com", + "first_name": "David", + "last_name": "Morris", + "gender": "female" + }, + { + "email": "amelia.walker@example.com", + "first_name": "Amelia", + "last_name": "Walker", + "gender": "male" + }, + { + "email": "jacob.richardson@example.com", + "first_name": "Jacob", + "last_name": "Richardson", + "gender": "female" + }, + { + "email": "michael.hill@example.com", + "first_name": "Michael", + "last_name": "Hill", + "gender": "male" + }, + { + "email": "sophia.peterson@example.com", + "first_name": "Sophia", + "last_name": "Peterson", + "gender": "female" + }, + { + "email": "william.bell@example.com", + "first_name": "William", + "last_name": "Bell", + "gender": "male" + }, + { + "email": "zoey.hall@example.com", + "first_name": "Zoey", + "last_name": "Hall", + "gender": "female" + }, + { + "email": "christopher.murphy@example.com", + "first_name": "Christopher", + "last_name": "Murphy", + "gender": "male" + }, + { + "email": "aubrey.myers@example.com", + "first_name": "Aubrey", + "last_name": "Myers", + "gender": "female" + }, + { + "email": "ryan.richardson@example.com", + "first_name": "Ryan", + "last_name": "Richardson", + "gender": "male" + }, + { + "email": "addison.wilson@example.com", + "first_name": "Addison", + "last_name": "Wilson", + "gender": "female" + }, + { + "email": "david.sanders@example.com", + "first_name": "David", + "last_name": "Sanders", + "gender": "male" + }, + { + "email": "madison.hill@example.com", + "first_name": "Madison", + "last_name": "Hill", + "gender": "female" + }, + { + "email": "jacob.peterson@example.com", + "first_name": "Jacob", + "last_name": "Peterson", + "gender": "male" + }, + { + "email": "scarlett.brooks@example.com", + "first_name": "Scarlett", + "last_name": "Brooks", + "gender": "female" + }, + { + "email": "michael.stewart@example.com", + "first_name": "Michael", + "last_name": "Stewart", + "gender": "male" + }, + { + "email": "grace.bennett@example.com", + "first_name": "Grace", + "last_name": "Bennett", + "gender": "female" + }, + { + "email": "logan.roberts@example.com", + "first_name": "Logan", + "last_name": "Roberts", + "gender": "male" + }, + { + "email": "sophia.baker@example.com", + "first_name": "Sophia", + "last_name": "Baker", + "gender": "female" + }, + { + "email": "john.gomez@example.com", + "first_name": "John", + "last_name": "Gomez", + "gender": "male" + }, + { + "email": "abigail.wood@example.com", + "first_name": "Abigail", + "last_name": "Wood", + "gender": "female" + }, + { + "email": "william.patterson@example.com", + "first_name": "William", + "last_name": "Patterson", + "gender": "male" + }, + { + "email": "lily.perez@example.com", + "first_name": "Lily", + "last_name": "Perez", + "gender": "female" + }, + { + "email": "james.hughes@example.com", + "first_name": "James", + "last_name": "Hughes", + "gender": "male" + }, + { + "email": "sophia.murphy@example.com", + "first_name": "Sophia", + "last_name": "Murphy", + "gender": "female" + }, + { + "email": "joseph.edwards@example.com", + "first_name": "Joseph", + "last_name": "Edwards", + "gender": "male" + }, + { + "email": "mia.washington@example.com", + "first_name": "Mia", + "last_name": "Washington", + "gender": "female" + }, + { + "email": "alexander.rogers@example.com", + "first_name": "Alexander", + "last_name": "Rogers", + "gender": "male" + }, + { + "email": "emily.gonzalez@example.com", + "first_name": "Emily", + "last_name": "Gonzalez", + "gender": "female" + }, + { + "email": "daniel.morris@example.com", + "first_name": "Daniel", + "last_name": "Morris", + "gender": "male" + }, + { + "email": "chloe.walker@example.com", + "first_name": "Chloe", + "last_name": "Walker", + "gender": "female" + }, + { + "email": "lucas.peterson@example.com", + "first_name": "Lucas", + "last_name": "Peterson", + "gender": "male" + }, + { + "email": "ava.watson@example.com", + "first_name": "Ava", + "last_name": "Watson", + "gender": "female" + }, + { + "email": "mason.howard@example.com", + "first_name": "Mason", + "last_name": "Howard", + "gender": "male" + }, + { + "email": "zoey.gonzalez@example.com", + "first_name": "Zoey", + "last_name": "Gonzalez", + "gender": "female" + } +] diff --git a/app/Actions/Lists/ListsLoader.php b/app/Actions/Lists/ListsLoader.php new file mode 100755 index 0000000..1e12aa9 --- /dev/null +++ b/app/Actions/Lists/ListsLoader.php @@ -0,0 +1,56 @@ +lists()->where('internal', false); + + if (Auth::id() !== $user->id) { + $builder->where('public', true); + } + + $pagination = (new Datasource($builder, $params))->paginate(); + + $pagination + ->loadCount('items') + ->load([ + 'items' => fn($query) => $query + ->orderBy('order') + ->where('order', '<=', 4) + ->compact(), + ]) + ->transform(function (Channel $list) { + $list->description = Str::limit($list->description, 80); + return $list; + }); + + return $pagination; + } + + public function allLists(array $params): AbstractPaginator + { + $builder = Channel::where('type', 'list') + ->where('internal', false) + ->with('user'); + + $pagination = (new Datasource($builder, $params))->paginate(); + + $pagination->loadCount('items'); + $pagination->transform(function (Channel $list) { + $list->description = Str::limit($list->description, 80); + return $list; + }); + + return $pagination; + } +} diff --git a/app/Actions/LocalSearch.php b/app/Actions/LocalSearch.php new file mode 100755 index 0000000..56241db --- /dev/null +++ b/app/Actions/LocalSearch.php @@ -0,0 +1,41 @@ +take(20) + ->get(); + + if ($with = Arr::get($params, 'with')) { + $with = array_filter(explode(',', $with)); + $titles->load($with); + } + } + + if (Arr::get($params, 'type') !== 'title') { + $people = Person::search($query) + ->take(20) + ->get(); + app(LoadPrimaryCredit::class)->execute($people); + } + + return $titles + ->concat($people) + ->slice(0, Arr::get($params, 'limit', 8)) + ->values(); + } +} diff --git a/app/Actions/News/ImportNewsFromRemoteProvider.php b/app/Actions/News/ImportNewsFromRemoteProvider.php new file mode 100755 index 0000000..b96e819 --- /dev/null +++ b/app/Actions/News/ImportNewsFromRemoteProvider.php @@ -0,0 +1,136 @@ +getArticles(), + )->map(function ($article) { + $article['slug'] = slugify(Str::limit($article['title'], 50)); + return $article; + }); + + $existing = NewsArticle::whereIn( + 'slug', + $newArticles->pluck('slug'), + )->get(); + + // filter out already existing articles + $newArticles = $newArticles + ->filter( + fn($newArticle) => !$existing->first( + fn($existingArticle) => $existingArticle['title'] === + $newArticle['title'] || + $existingArticle['slug'] === $newArticle['slug'], + ), + ) + ->unique('slug'); + + $articlesPayload = $newArticles->map(function ($article, $index) { + $date = isset($article['date']) + ? Carbon::parse($article['date']) + : Carbon::now(); + return [ + 'title' => $article['title'], + 'body' => $article['body'], + 'slug' => $article['slug'], + 'image' => $article['image'] ?? null, + 'source' => $article['source'] ?? null, + 'source_url' => $article['source_url'] ?? null, + 'byline' => $article['byline'] ?? null, + 'created_at' => $date, + 'updated_at' => $date, + ]; + }); + + NewsArticle::insert($articlesPayload->toArray()); + $this->attachArticlesToRelatedModels($newArticles); + } + + protected function attachArticlesToRelatedModels(Collection $articles): void + { + // fetch inserted articles from db + $dbArticles = NewsArticle::whereIn( + 'slug', + $articles->pluck('slug'), + )->get(['id', 'slug']); + + $titleImdbIds = []; + $personImdbIds = []; + + // generate [article_slug => [imdb_id, imdb_id, ...]] arrays + foreach ($articles as $article) { + if (isset($article['imdb_title_ids'])) { + foreach ($article['imdb_title_ids'] as $imdbTitleId) { + $titleImdbIds[$article['slug']][] = $imdbTitleId; + } + } + if (isset($article['imdb_person_ids'])) { + foreach ($article['imdb_person_ids'] as $imdbPersonId) { + $personImdbIds[$article['slug']][] = $imdbPersonId; + } + } + } + + // fetch titles and people by imdb_id as [imdb_id => id] arrays + $titles = Title::whereIn( + 'imdb_id', + collect($titleImdbIds) + ->values() + ->flatten(), + ) + ->pluck('id', 'imdb_id') + ->toArray(); + $people = Person::whereIn( + 'imdb_id', + collect($personImdbIds) + ->values() + ->flatten(), + ) + ->pluck('id', 'imdb_id') + ->toArray(); + + // generate titles payload for pivot table + $payload = []; + foreach ($titleImdbIds as $slug => $imdbIds) { + foreach ($imdbIds as $imdbId) { + if (isset($titles[$imdbId])) { + $payload[] = [ + 'article_id' => $dbArticles->firstWhere('slug', $slug) + ->id, + 'model_id' => $titles[$imdbId], + 'model_type' => Title::MODEL_TYPE, + ]; + } + } + } + + // generate people payload for pivot table + foreach ($personImdbIds as $slug => $imdbIds) { + foreach ($imdbIds as $imdbId) { + if (isset($people[$imdbId])) { + $payload[] = [ + 'article_id' => $dbArticles->firstWhere('slug', $slug) + ->id, + 'model_id' => $people[$imdbId], + 'model_type' => Person::class, + ]; + } + } + } + + DB::table('news_article_models')->insert($payload); + } +} diff --git a/app/Actions/People/DeletePeople.php b/app/Actions/People/DeletePeople.php new file mode 100755 index 0000000..a05b0c5 --- /dev/null +++ b/app/Actions/People/DeletePeople.php @@ -0,0 +1,19 @@ +whereIn('id', $ids) + ->delete(); + DB::table('creditables') + ->whereIn('person_id', $ids) + ->delete(); + } +} diff --git a/app/Actions/People/GetPersonCredits.php b/app/Actions/People/GetPersonCredits.php new file mode 100755 index 0000000..6520c55 --- /dev/null +++ b/app/Actions/People/GetPersonCredits.php @@ -0,0 +1,283 @@ +titleId = Arr::get($options, 'titleId'); + $credits = $this->titleId ? [] : $this->getTitleCredits($person); + $seasonCredits = $this->getSeasonCredits($person); + $episodeCredits = $this->getEpisodeCredits($person); + + $mergedCredits = $this->mergeCredits( + Arr::get($credits, 'all', []), + $seasonCredits, + $episodeCredits, + ); + $mergedCredits = $this->separateSelfCreditsAndSort($mergedCredits); + + return [ + 'credits' => $mergedCredits, + 'knownFor' => Arr::get($credits, 'knownFor', []), + 'total_credits_count' => array_reduce( + $mergedCredits, + fn($carry, $item) => $carry + count($item), + 0, + ), + ]; + } + + private function mergeCredits($credits1, $credits2, $credits3): array + { + $mergedCredits = array_merge_recursive($credits1, $credits2, $credits3); + + return array_map(function ($titles) { + // sort titles by year + usort($titles, fn($a, $b) => $b['year'] - $a['year']); + + $unique = []; + + // if this title already exists and existing + // title has episodes property, continue, + // otherwise push title into 'unique' array + foreach ($titles as $title) { + $existing = Arr::get($unique, $title['id']); + if ($existing) { + $existing['credited_episode_count'] = + Arr::get($existing, 'credited_episode_count', 0) + + Arr::get($title, 'credited_episode_count', 0); + $existing['episodes'] = collect( + array_merge( + Arr::get($existing, 'episodes', []), + Arr::get($title, 'episodes', []), + ), + ) + ->unique('id') + ->toArray(); + if (!$this->titleId) { + $existing['episodes'] = array_slice( + $existing['episodes'], + 0, + 5, + ); + } + $unique[$title['id']] = $existing; + } else { + $unique[$title['id']] = $title; + } + } + + return array_values($unique); + }, $mergedCredits); + } + + private function getTitleCredits(Person $person): array + { + $credits = $person->credits()->get(); + + // generate known for list for actors "known_for" department. + $allKnownFor = $credits + ->filter(function (Title $credit) use ($person) { + $knownFor = + strtolower($person->known_for) === 'acting' + ? 'actors' + : $person->known_for; + return $credit->pivot->department === strtolower($knownFor); + }) + ->unique(); + + $knownFor = $allKnownFor->where('pivot.order', '<', 10); + + if ($knownFor->count() < 4) { + $knownFor = $allKnownFor; + } + + // sort by person credit "order" for title as well as title popularity + $knownFor = $knownFor + ->sortBy(function ($title) { + $order = $title->pivot->order; + $popularity = $title->popularity; + return $order - $popularity; + }) + ->slice(0, 6) + ->values(); + + // cast to array, so poster/backdrop is not removed later. + $knownFor = $knownFor->load(['primaryVideo'])->toArray(); + + // remove any data not needed to render person filmography + $credits = $credits + ->map(function (Title $credit) { + unset($credit['backdrop']); + return $credit; + }) + ->groupBy('pivot.department'); + + return ['all' => $credits->toArray(), 'knownFor' => $knownFor]; + } + + /** + * Get credits for all series seasons person is attached to. + */ + private function getSeasonCredits(Person $person): array + { + $seasons = $person + ->seasonCredits($this->titleId) + ->with([ + 'title' => fn($query) => $query->select( + 'id', + 'name', + 'release_date', + 'poster', + ), + 'episodes' => fn($query) => $query + ->select( + 'id', + 'name', + 'release_date', + 'season_id', + 'season_number', + 'episode_number', + 'title_id', + ) + ->orderBy('season_number', 'desc') + ->orderBy('episode_number', 'desc'), + ]) + ->get(); + + // group all seasons by department, for example "production" + $groupedSeasons = $seasons->groupBy('pivot.department'); + + return $groupedSeasons + ->map(function (Collection $departmentGroup) { + $seasonsGroupedByTitle = $departmentGroup->groupBy('title.id'); + + // attach episodes from all seasons to title + return $seasonsGroupedByTitle + ->map(function (Collection $titleSeasons) { + $title = $titleSeasons + ->first(fn($s) => $s->title) + ->title->toArray(); + + //get episodes from each season and move season "pivot" data to each episode + $episodesFromAllSeasons = $titleSeasons + ->pluck('episodes') + ->flatten() + ->values() + ->map(function ($episode) use ($titleSeasons) { + $episode->pivot = $titleSeasons + ->first() + ->pivot->toArray(); + return $episode; + }); + $title[ + 'credited_episode_count' + ] = $episodesFromAllSeasons->count(); + if (!$this->titleId) { + $episodesFromAllSeasons = $episodesFromAllSeasons->take( + 5, + ); + } + $title['episodes'] = $episodesFromAllSeasons->toArray(); + return $title; + }) + ->values(); + }) + ->toArray(); + } + + /** + * Get all individual episodes person is credited for. + * + * This will return array grouped by department, and + * series with all episodes person is credited for attached + * to that series. + * + * @param Person $person + * @return array + */ + private function getEpisodeCredits(Person $person) + { + $episodes = $person + ->episodeCredits($this->titleId) + ->with([ + 'title' => function (BelongsTo $query) { + $query->select('id', 'name', 'release_date', 'poster'); + }, + ]) + ->get(); + + $groupedByDep = $episodes->groupBy('pivot.department'); + + return $groupedByDep + ->map( + fn(Collection $episodes) => $episodes + ->groupBy('title.id') + ->map(function (Collection $episodes) { + if (!$episodes->first()->title) { + return null; + } + $title = $episodes->first()->title->toArray(); + $episodes = $episodes->map(function ($episode) { + unset($episode->title); + return $episode; + }); + $title['credited_episode_count'] = $episodes->count(); + if (!$this->titleId) { + $episodes = $episodes->take(5); + } + $title['episodes'] = $episodes->toArray(); + return $title; + }) + ->filter() + ->values(), + ) + ->toArray(); + } + + private function separateSelfCreditsAndSort(array $credits): array + { + if (!isset($credits['cast'])) { + return $credits; + } + + $cast = []; + $self = []; + foreach ($credits['cast'] as $credit) { + $char = isset($credit['pivot']['character']) + ? strtolower($credit['pivot']['character']) + : null; + if ( + $char && + ($char === 'self' || Str::contains($char, 'himself')) + ) { + $self[] = $credit; + } else { + $cast[] = $credit; + } + } + $credits['cast'] = $cast; + + // sort before adding "self" to array as that should be last always + uksort( + $credits, + fn($a, $b) => (is_countable($credits[$b]) + ? count($credits[$b]) + : 0) - (is_countable($credits[$a]) ? count($credits[$a]) : 0), + ); + + $credits['self'] = $self; + return $credits; + } +} diff --git a/app/Actions/People/LoadPrimaryCredit.php b/app/Actions/People/LoadPrimaryCredit.php new file mode 100755 index 0000000..4bad324 --- /dev/null +++ b/app/Actions/People/LoadPrimaryCredit.php @@ -0,0 +1,74 @@ +join('creditables', 'titles.id', '=', 'creditables.creditable_id') + ->where('creditables.creditable_type', Title::MODEL_TYPE) + ->whereIn('creditables.person_id', $people->pluck('id')) + // this scope will mess up binding merging below + ->withoutGlobalScope('adult') + ->when( + !config('tmdb.includeAdult'), + fn($q) => $q->where('adult', false), + ); + + // cache syntax error, if mysql version does not support partition + try { + $items = DB::table( + DB::raw("({$titleSelect->toSql()}) as laravel_table"), + ) + ->select('*') + ->mergeBindings($titleSelect->getQuery()) + ->where('laravel_row', '<=', 1) + ->orderBy('laravel_row') + ->get(); + } catch (QueryException $e) { + return; + } + + $credits = Title::hydrate($items->toArray()); + + $people->each(function ($person) use ($credits) { + $credit = $credits->first( + fn($credit) => $credit->pivot_person_id === $person->id, + ); + if ($credit) { + $person->primary_credit = [ + 'id' => $credit->id, + 'is_series' => $credit->is_series, + 'name' => $credit->name, + 'year' => $credit->year, + 'model_type' => $credit->model_type, + ]; + } + }); + } +} diff --git a/app/Actions/People/PaginatePeople.php b/app/Actions/People/PaginatePeople.php new file mode 100755 index 0000000..217d777 --- /dev/null +++ b/app/Actions/People/PaginatePeople.php @@ -0,0 +1,62 @@ +select([ + 'id', + 'name', + 'birth_date', + 'death_date', + 'poster', + 'popularity', + ]); + } + + $datasource = new Datasource($builder, $params); + + // prevent duplicate items when ordering by columns that are not + // guaranteed to be unique (name, popularity, age etc.) + $datasource->secondaryOrderCol = 'id'; + + if (!Arr::get($params, 'order') && !Arr::get($params, 'orderBy')) { + $datasource->order = [ + 'col' => 'popularity', + 'dir' => 'desc', + ]; + } + + if ( + $datasource->getOrder()['col'] === 'popularity' && + ($min = config('content.people_index_min_popularity')) + ) { + $builder->where('popularity', '>', $min); + } + + $pagination = $datasource->paginate(); + + if (!$isCompact) { + $pagination->transform(function (Person $person) { + $person->description = Str::limit($person->description, 500); + return $person; + }); + + //app(LoadPrimaryCredit::class)->execute($pagination); + } + + return $pagination; + } +} diff --git a/app/Actions/People/StorePersonData.php b/app/Actions/People/StorePersonData.php new file mode 100755 index 0000000..7096e23 --- /dev/null +++ b/app/Actions/People/StorePersonData.php @@ -0,0 +1,68 @@ +person = $person; + $this->data = $data; + + $this->persistData(); + $this->persistRelations(); + + return $this->person; + } + + private function persistData(): void + { + $personData = array_filter($this->data, function ( + $value, // make sure we don't overwrite existing values with null + ) { + if (is_array($value)) { + return false; + } + + // if fully_synced is true, override everything and erase any previously set values. + // For example if "death_date" was previously set on a person and tmdb now returns null for "death_date", set "death_date" to null in database. + if ( + config('common.site.tmdb_delete_when_sync') && + $this->data['fully_synced'] + ) { + return true; + } + + // if "tmdb_delete_when_sync" is false, don't clear existing values, as values set from admin manually might be erased + return !is_null($value); + }); + + $this->person->fill($personData)->save(); + } + + private function persistRelations(): void + { + $relations = array_filter($this->data, fn($value) => is_array($value)); + + foreach ($relations as $name => $values) { + switch ($name) { + case 'credits': + app(StoreCredits::class)->execute($this->person, $values); + break; + case 'images': + $this->storeImages($values, $this->person); + break; + } + } + } +} diff --git a/app/Actions/Plays/BuildPlaysReport.php b/app/Actions/Plays/BuildPlaysReport.php new file mode 100755 index 0000000..3801dc8 --- /dev/null +++ b/app/Actions/Plays/BuildPlaysReport.php @@ -0,0 +1,399 @@ +params = $params; + $this->builder = $this->createBuilder(); + + $this->dateRange = new MetricDateRange( + start: $this->params['startDate'] ?? null, + end: $this->params['endDate'] ?? null, + timezone: $this->params['timezone'] ?? null, + ); + + $metrics = explode(',', Arr::get($params, 'metrics', 'plays')); + + return collect($metrics) + ->mapWithKeys(function ($metric) { + if ($metric === 'movies') { + return ['movies' => $this->getTitlesMetric(false)]; + } elseif ($metric === 'series') { + return ['series' => $this->getTitlesMetric(true)]; + } else { + $method = sprintf('get%sMetric', ucfirst($metric)); + if (method_exists($this, $method)) { + return [$metric => $this->$method()]; + } + return [$metric => []]; + } + }) + ->toArray(); + } + + protected function createBuilder(): Builder + { + $model = Arr::get($this->params, 'model', ''); + $parts = explode('=', $model); + + // might send track_play=0, check if variable is set, instead of being truthy + if (!isset($parts[0]) || !isset($parts[1])) { + $parts = ['video_play', 0]; + } + + $model = modelTypeToNamespace($parts[0]); + $this->modelId = (int) $parts[1]; + + switch ($model) { + case VideoPlay::class: + // all plays, not scoped to any resource (for admin area) + Gate::authorize('admin.access'); + $builder = VideoPlay::query(); + break; + case Video::class: + $video = Video::findOrFail($this->modelId); + Gate::authorize('update', $video); + $builder = $video->plays()->getQuery(); + break; + case Movie::class: + case Series::class: + case Title::class: + $title = Title::findOrFail($this->modelId); + Gate::authorize('update', $title); + $builder = $title->plays()->getQuery(); + break; + case Season::class: + $season = Season::with(['title'])->findOrFail($this->modelId); + Gate::authorize('update', $season->title); + $builder = VideoPlay::join( + 'videos', + 'video_plays.video_id', + '=', + 'videos.id', + ) + ->join('titles', 'videos.title_id', '=', 'titles.id') + ->where('titles.id', $season->title_id) + ->where('videos.season_num', $season->number); + break; + case Episode::class: + $episode = Episode::with(['title'])->findOrFail($this->modelId); + Gate::authorize('update', $episode->title); + $builder = $episode->plays()->getQuery(); + break; + default: + throw new Exception(); + } + + return $builder; + } + + protected function getPlaysMetric(): array + { + if (config('common.site.fake_plays_data')) { + $data = (new GenerateFakePlaysData())->playsTrend( + $this->builder, + $this->dateRange, + ); + } else { + $data = (new Trend( + $this->builder, + dateRange: $this->dateRange, + ))->count(); + } + + return [ + 'granularity' => $this->dateRange->granularity, + 'total' => array_sum(Arr::pluck($data, 'value')), + 'datasets' => [ + [ + 'label' => __('Plays'), + 'data' => $data, + ], + ], + ]; + } + + protected function getDevicesMetric(): array + { + return $this->getPartitionMetric('device', 5); + } + + protected function getBrowsersMetric(): array + { + return $this->getPartitionMetric('browser', 8); + } + + protected function getPlatformsMetric(): array + { + return $this->getPartitionMetric('platform', 5); + } + + protected function getTitlesMetric(bool $isSeries = null): array + { + if (config('common.site.fake_plays_data')) { + $data = (new GenerateFakePlaysData())->titles($isSeries); + } else { + $data = (new Partition( + $this->builder + ->join('videos', 'video_plays.video_id', '=', 'videos.id') + ->join('titles', 'videos.title_id', '=', 'titles.id') + ->when( + !is_null($isSeries), + fn($query) => $query->where('is_series', $isSeries), + ) + ->orderBy('aggregate', 'desc'), + groupBy: 'title_id', + dateRange: $this->dateRange, + limit: 30, + ))->count(); + + $titles = Title::whereIn('id', Arr::pluck($data, 'label')) + ->compact() + ->get(); + + $data = array_map(function ($item) use ($titles) { + $title = $titles->firstWhere('id', $item['label']); + $item['model'] = $title; + $item['label'] = $title->name; + return $item; + }, $data); + } + + return [ + 'datasets' => [ + [ + 'label' => __('Plays'), + 'data' => $data, + ], + ], + ]; + } + + protected function getSeasonsMetric(): array + { + if (config('common.site.fake_plays_data')) { + $data = (new GenerateFakePlaysData())->seasons($this->modelId); + } else { + $data = (new Partition( + $this->builder + ->whereNotNull('season_num') + ->orderBy('aggregate', 'desc'), + groupBy: 'season_num', + dateRange: $this->dateRange, + limit: 30, + ))->count(); + + $seasons = Season::where('title_id', $this->modelId) + ->whereIn('number', Arr::pluck($data, 'label')) + ->with(['title' => fn($query) => $query->compact()]) + ->get(); + + $data = array_map(function ($item) use ($seasons) { + $season = $seasons->firstWhere('number', (int) $item['label']); + $item['model'] = $season; + $item['label'] = __('Season :number', [ + 'number' => $season->number, + ]); + return $item; + }, $data); + } + + return [ + 'datasets' => [ + [ + 'label' => __('Plays'), + 'data' => $data, + ], + ], + ]; + } + + protected function getEpisodesMetric(): array + { + if (config('common.site.fake_plays_data')) { + $data = (new GenerateFakePlaysData())->episodes($this->modelId); + } else { + $data = (new Partition( + $this->builder + ->whereNotNull('episode_id') + ->orderBy('aggregate', 'desc'), + groupBy: 'episode_id', + dateRange: $this->dateRange, + limit: 30, + ))->count(); + + $episodes = Episode::whereIn('id', Arr::pluck($data, 'label')) + ->with(['title' => fn($query) => $query->compact()]) + ->get(); + + $data = array_map(function ($item) use ($episodes) { + $episode = $episodes->firstWhere('id', (int) $item['label']); + $item['model'] = $episode; + $item['label'] = __('Season :s, Episode :e', [ + 's' => $episode->season_number, + 'e' => $episode->number, + ]); + return $item; + }, $data); + } + + return [ + 'datasets' => [ + [ + 'label' => __('Plays'), + 'data' => $data, + ], + ], + ]; + } + + protected function getVideosMetric(): array + { + if (config('common.site.fake_plays_data')) { + $data = (new GenerateFakePlaysData())->videos(); + } else { + $data = (new Partition( + $this->builder->orderBy('aggregate', 'desc'), + groupBy: 'video_id', + dateRange: $this->dateRange, + limit: 30, + ))->count(); + + $videos = Video::whereIn('id', Arr::pluck($data, 'label')) + ->with(['title' => fn($query) => $query->compact()]) + ->get(); + + $data = array_map(function ($item) use ($videos) { + $video = $videos->firstWhere('id', $item['label']); + $item['model'] = $video; + $item['label'] = $video?->name; + return $item; + }, $data); + } + + $data = array_values(array_filter($data, fn($item) => $item['label'])); + + return [ + 'datasets' => [ + [ + 'label' => __('Plays'), + 'data' => $data, + ], + ], + ]; + } + + protected function getUsersMetric(): array + { + if (config('common.site.fake_plays_data')) { + $data = (new GenerateFakePlaysData())->users(); + } else { + $data = (new Partition( + $this->builder->orderBy('aggregate', 'desc'), + groupBy: 'user_id', + dateRange: $this->dateRange, + limit: 30, + ))->count(); + + $userIds = collect($data) + ->pluck('label') + ->filter() + ->unique(); + $users = User::whereIn('id', $userIds)->get(); + + $data = array_map(function ($item) use ($users) { + $user = + $users->firstWhere('id', $item['label']) ?? + new User(['first_name' => __('Guest user')]); + $item['model'] = $user; + $item['label'] = $user->display_name; + return $item; + }, $data); + } + + return [ + 'datasets' => [ + [ + 'label' => __('Plays'), + 'data' => $data, + ], + ], + ]; + } + + protected function getLocationsMetric(): array + { + $metric = $this->getPartitionMetric('location'); + + $countries = app(ValueLists::class)->countries(); + + $metric['datasets'][0]['data'] = array_map(function ($location) use ( + $countries, + $metric, + ) { + // only short country code is stored in DB, get and return full country name as well + $location['code'] = strtolower($location['label']); + $location['label'] = + Arr::first( + $countries, + fn($country) => strtolower($country['code']) === + strtolower($location['code']), + )['name'] ?? $location['label']; + return $location; + }, $metric['datasets'][0]['data']); + + return $metric; + } + + protected function getPartitionMetric( + string $groupBy, + int $limit = 10, + ): array { + if (config('common.site.fake_plays_data')) { + $data = (new GenerateFakePlaysData())->partitionMetric($groupBy); + } else { + $data = (new Partition( + $this->builder, + groupBy: $groupBy, + dateRange: $this->dateRange, + limit: $limit, + ))->count(); + } + + return [ + 'datasets' => [ + [ + 'label' => __('Plays'), + 'data' => $data, + ], + ], + ]; + } +} diff --git a/app/Actions/Plays/GenerateFakePlaysData.php b/app/Actions/Plays/GenerateFakePlaysData.php new file mode 100755 index 0000000..9f515bc --- /dev/null +++ b/app/Actions/Plays/GenerateFakePlaysData.php @@ -0,0 +1,137 @@ +count(); + } + + public function partitionMetric(string $groupBy): array + { + $method = + 'build' . + Str::of($groupBy) + ->ucfirst() + ->plural() + ->toString() . + 'Metric'; + return (new BuildDemoAnalyticsReport())->$method(); + } + + public function titles(bool $isSeries = null): array + { + return Title::orderBy('popularity', 'desc') + ->when( + !is_null($isSeries), + fn($query) => $query->where('is_series', $isSeries), + ) + ->where('language', 'en') + ->limit(30) + ->compact() + ->get() + ->map( + fn(Title $title) => [ + 'label' => $title->name, + 'value' => random_int(50, 1654), + 'percentage' => random_int(1, 100), + 'model' => $title, + ], + ) + ->sortByDesc('value') + ->values() + ->toArray(); + } + + public function seasons(int $titleId): array + { + return Season::where('title_id', $titleId) + ->with(['title' => fn($query) => $query->compact()]) + ->get() + ->map(function (Season $season) { + return [ + 'label' => $season['name'], + 'value' => random_int(50, 1654), + 'percentage' => random_int(1, 100), + 'model' => $season, + ]; + }) + ->sortByDesc('value') + ->values() + ->toArray(); + } + + public function episodes(int $titleId): array + { + return Episode::where('title_id', $titleId) + ->with(['title' => fn($query) => $query->compact()]) + ->get() + ->map(function (Episode $episode) { + return [ + 'label' => $episode['name'], + 'value' => random_int(50, 1654), + 'percentage' => random_int(1, 100), + 'model' => $episode, + ]; + }) + ->sortByDesc('value') + ->values() + ->toArray(); + } + + public function videos(): array + { + return Title::orderBy('popularity', 'desc') + ->where('language', 'en') + ->limit(30) + ->compact() + ->with('videos') + ->get() + ->filter(fn(Title $title) => $title->videos->isNotEmpty()) + ->map(function (Title $title) { + $video = $title->videos->random()->toArray(); + $title->unsetRelation('videos'); + $video['title'] = $title->toArray(); + return [ + 'label' => $video['name'], + 'value' => random_int(50, 1654), + 'percentage' => random_int(1, 100), + 'model' => $video, + ]; + }) + ->sortByDesc('value') + ->values() + ->toArray(); + } + + public function users(): array + { + return User::inRandomOrder() + ->limit(30) + ->compact() + ->get() + ->map( + fn(User $user) => [ + 'label' => $user->display_name, + 'value' => random_int(50, 1654), + 'percentage' => random_int(1, 100), + 'model' => $user, + ], + ) + ->sortByDesc('value') + ->values() + ->toArray(); + } +} diff --git a/app/Actions/Plays/LogVideoPlay.php b/app/Actions/Plays/LogVideoPlay.php new file mode 100755 index 0000000..2e1ec15 --- /dev/null +++ b/app/Actions/Plays/LogVideoPlay.php @@ -0,0 +1,92 @@ +updateTimeWatched($video, $params); + } else { + $this->logVideoPlay($video); + } + } + + private function updateTimeWatched(Video $video, array $params): void + { + $lastPlay = $video + ->plays() + ->forCurrentUser() + ->orderBy('created_at', 'desc') + ->first(); + + $timeWatched = round($params['currentTime']); + $duration = round($params['duration']); + + // if user watched over 95%, we can assume video is fully watched + $fullyWatched = $timeWatched >= (95 / 100) * $duration; + + // if fully watched or watched less than 60 seconds, set time watched to 0 + if ($fullyWatched || $timeWatched < 60) { + $timeWatched = 0; + } + + if ($lastPlay) { + $lastPlay + ->fill([ + 'time_watched' => $timeWatched, + 'duration' => round($params['duration']), + ]) + ->save(); + } + } + + private function logVideoPlay(Video $video): void + { + if (!$this->alreadyLoggedToday($video)) { + $ip = getIp(); + $video->plays()->create([ + 'location' => $this->getLocation($ip), + 'platform' => strtolower(Agent::platform()), + 'device' => $this->getDevice(), + 'browser' => strtolower(Agent::browser()), + 'user_id' => Auth::id(), + 'ip' => $ip, + ]); + } + } + + protected function alreadyLoggedToday(Video $video): bool + { + return $video + ->plays() + ->forCurrentUser() + ->whereBetween('created_at', [ + Carbon::now()->subDay(), + Carbon::now(), + ]) + ->exists(); + } + + protected function getDevice(): string + { + if (Agent::isMobile()) { + return 'mobile'; + } elseif (Agent::isTablet()) { + return 'tablet'; + } else { + return 'desktop'; + } + } + + protected function getLocation(string $ip): string + { + return strtolower(geoip($ip)['iso_code']); + } +} diff --git a/app/Actions/Reviews/UpdateReviewableAverageScore.php b/app/Actions/Reviews/UpdateReviewableAverageScore.php new file mode 100755 index 0000000..b1ed97a --- /dev/null +++ b/app/Actions/Reviews/UpdateReviewableAverageScore.php @@ -0,0 +1,32 @@ +where('reviewable_type', $reviewableType) + ->where('reviewable_id', $reviewableId) + ->select( + DB::raw('avg(`score`) as average'), + DB::raw('count(*) as count'), + ) + ->first(); + + + $average = number_format((float) $votes['average'], 1); + + // title or episode + $model = app(modelTypeToNamespace($reviewableType))->find( + $reviewableId, + ); + $model->local_vote_average = $average; + $model->local_vote_count = $votes['count']; + $model->save(); + } +} diff --git a/app/Actions/Titles/DeleteSeasons.php b/app/Actions/Titles/DeleteSeasons.php new file mode 100755 index 0000000..e7c8e35 --- /dev/null +++ b/app/Actions/Titles/DeleteSeasons.php @@ -0,0 +1,38 @@ +whereIn('creditable_id', $seasonIds) + ->where('creditable_type', Season::MODEL_TYPE) + ->delete(); + + app(Season::class) + ->whereIn('id', $seasonIds) + ->delete(); + + // episodes + $episodeIds = app(Episode::class) + ->whereIn('season_id', $seasonIds) + ->pluck('id'); + + DB::table('creditables') + ->whereIn('creditable_id', $episodeIds) + ->where('creditable_type', Episode::MODEL_TYPE) + ->delete(); + + app(Episode::class) + ->whereIn('id', $episodeIds) + ->delete(); + } +} diff --git a/app/Actions/Titles/DeleteTitles.php b/app/Actions/Titles/DeleteTitles.php new file mode 100755 index 0000000..369d39b --- /dev/null +++ b/app/Actions/Titles/DeleteTitles.php @@ -0,0 +1,104 @@ +whereIn('title_id', $titleIds) + ->pluck('id'); + app(DeleteSeasons::class)->execute($seasonIds); + + // credits + DB::table('creditables') + ->whereIn('creditable_id', $titleIds) + ->where('creditable_type', Title::MODEL_TYPE) + ->delete(); + + // images + app(Image::class) + ->whereIn('model_id', $titleIds) + ->where('model_type', Title::MODEL_TYPE) + ->delete(); + + // list items + app(Listable::class) + ->whereIn('listable_id', $titleIds) + ->where('listable_type', Title::MODEL_TYPE) + ->delete(); + + // channel items + DB::table('channelables') + ->whereIn('channelable_id', $titleIds) + ->where('channelable_type', Title::MODEL_TYPE) + ->delete(); + + // reviews + Review::whereIn('reviewable_id', $titleIds) + ->where('reviewable_type', Title::MODEL_TYPE) + ->delete(); + + // comments + Comment::whereIn('commentable_id', $titleIds) + ->where('commentable_type', Title::MODEL_TYPE) + ->delete(); + + // tags + DB::table('taggables') + ->whereIn('taggable_id', $titleIds) + ->where('taggable_type', Title::MODEL_TYPE) + ->delete(); + + // keywords + DB::table('keyword_title') + ->whereIn('title_id', $titleIds) + ->delete(); + + // countries + DB::table('country_title') + ->whereIn('title_id', $titleIds) + ->delete(); + + // genres + DB::table('genre_title') + ->whereIn('title_id', $titleIds) + ->delete(); + + // videos + $videoIds = app(Video::class) + ->whereIn('title_id', $titleIds) + ->pluck('id'); + app(Video::class) + ->whereIn('id', $videoIds) + ->delete(); + + DB::table('video_votes') + ->whereIn('video_id', $videoIds) + ->delete(); + DB::table('video_captions') + ->whereIn('video_id', $videoIds) + ->delete(); + DB::table('video_reports') + ->whereIn('video_id', $videoIds) + ->delete(); + DB::table('video_plays') + ->whereIn('video_id', $videoIds) + ->delete(); + + // titles + Title::withoutGlobalScope('adult') + ->whereIn('id', $titleIds) + ->delete(); + } +} diff --git a/app/Actions/Titles/HandlesEncodedTmdbId.php b/app/Actions/Titles/HandlesEncodedTmdbId.php new file mode 100755 index 0000000..3390e0f --- /dev/null +++ b/app/Actions/Titles/HandlesEncodedTmdbId.php @@ -0,0 +1,40 @@ + $parts[0], + 'type' => $parts[1], + 'id' => $parts[2], + ]; + } else { + return ['provider' => null, 'type' => null, 'id' => null]; + } + } + + public static function decodeTmdbIdOrFail(string $encodedId): array + { + [$provider, $type, $id] = array_values( + static::decodeTmdbId($encodedId), + ); + if (!$provider || !$type || !$id) { + abort(404); + } + return [(int) $id, $type]; + } +} diff --git a/app/Actions/Titles/HasCreditableRelation.php b/app/Actions/Titles/HasCreditableRelation.php new file mode 100755 index 0000000..9c1bbe9 --- /dev/null +++ b/app/Actions/Titles/HasCreditableRelation.php @@ -0,0 +1,75 @@ +morphToMany(Person::class, 'creditable')->withPivot([ + 'id', + 'job', + 'department', + 'order', + 'character', + ]); + + $query = $query->select(['people.id', 'name', 'poster']); + + // order by department first, so we always get director, + // writers and creators, even if limit is applied to this query + $prefix = DB::getTablePrefix(); + return $query + ->orderBy( + DB::raw( + "FIELD(department, 'directing', 'creators', 'writing', 'actors')", + ), + ) + // should be "desc" and not "asc" because "minus" is added which will reverse order + ->orderBy(DB::raw("-{$prefix}creditables.order"), 'desc'); + } + + public function updateCredit(int $pivotId, array $payload): void + { + // lowercase payload + $payload = collect($payload) + ->mapWithKeys(function ($value, $key) { + if ($key === 'department' || $key === 'job') { + $value = strtolower($value); + } + return [$key => $value]; + }) + ->toArray(); + + $this->credits() + ->newPivotQuery() + ->where('id', $pivotId) + ->update($payload); + } + + public function createCredit(array $payload): void + { + // lowercase payload + $payload = collect($payload) + ->mapWithKeys(function ($value, $key) { + if ($key === 'department' || $key === 'job') { + $value = strtolower($value); + } + return [$key => $value]; + }) + ->toArray(); + + if ($payload['department'] === 'actors') { + $payload['order'] = + $this->credits() + ->wherePivot('department', 'actors') + ->count() + 1; + } + + $this->credits()->attach($payload['person_id'], $payload); + } +} diff --git a/app/Actions/Titles/HasVideoRelation.php b/app/Actions/Titles/HasVideoRelation.php new file mode 100755 index 0000000..c18fdc2 --- /dev/null +++ b/app/Actions/Titles/HasVideoRelation.php @@ -0,0 +1,56 @@ +hasMany(Video::class)->applySelectedSort(); + } + + public function primaryVideo(): HasOne + { + $preferFull = settings('streaming.prefer_full'); + return $this->hasOne(Video::class) + ->when($preferFull, function ($query) { + $query->where('category', 'full'); + }) + ->select([ + 'id', + 'title_id', + 'name', + 'category', + 'episode_id', + 'season_num', + 'episode_num', + ]) + ->when( + // is series or movie and not specific episode + fn() => static::class !== Episode::class && $preferFull, + function ($builder) { + return $builder->where(function ($builder) { + // video attached directly to movie or series + $builder + ->where( + fn($builder) => $builder + ->whereNull('season_num') + ->whereNull('episode_num'), + ) + // video attached to first episode of series + ->orWhere( + fn($builder) => $builder + ->where('season_num', 1) + ->where('episode_num', 1), + ); + }); + }, + ) + ->applySelectedSort(); + } +} diff --git a/app/Actions/Titles/InsertsTmdbTitleOrPerson.php b/app/Actions/Titles/InsertsTmdbTitleOrPerson.php new file mode 100755 index 0000000..3b39e68 --- /dev/null +++ b/app/Actions/Titles/InsertsTmdbTitleOrPerson.php @@ -0,0 +1,53 @@ +map(function ($value) { + unset($value['relation_data']); + unset($value['model_type']); + unset($value['id']); + return $value; + }); + + $select = + static::class === Person::class + ? ['id', 'tmdb_id', 'name'] + : ['id', 'tmdb_id', 'name', 'is_series']; + + $existing = $this->withoutGlobalScope('adult') + ->select($select) + ->whereIn('tmdb_id', $tmdbItems->pluck('tmdb_id')) + ->get() + ->mapWithKeys(fn($item) => [$item['tmdb_id'] => $item]); + + $new = $tmdbItems + ->filter(fn($item) => !isset($existing[$item['tmdb_id']])) + ->values(); + + if ($new->isNotEmpty()) { + $new->transform(function ($item) { + $item['created_at'] = Arr::get( + $item, + 'created_at', + Carbon::now(), + ); + return $item; + }); + $this->insert($new->toArray()); + return $this->whereIn('tmdb_id', $tmdbItems->pluck('tmdb_id')) + ->select($select) + ->get(); + } else { + return $existing; + } + } +} diff --git a/app/Actions/Titles/LoadSeasonEpisodeNumbers.php b/app/Actions/Titles/LoadSeasonEpisodeNumbers.php new file mode 100755 index 0000000..febcda3 --- /dev/null +++ b/app/Actions/Titles/LoadSeasonEpisodeNumbers.php @@ -0,0 +1,18 @@ +where('title_id', $titleId) + ->where('season_number', $seasonNumber) + ->orderBy('episode_number', 'asc') + ->pluck('episode_number') + ->toArray(); + } +} diff --git a/app/Actions/Titles/Retrieve/GetRelatedTitles.php b/app/Actions/Titles/Retrieve/GetRelatedTitles.php new file mode 100755 index 0000000..ebf26db --- /dev/null +++ b/app/Actions/Titles/Retrieve/GetRelatedTitles.php @@ -0,0 +1,54 @@ +getByTags($title, $params); + + if ($titleIds->isNotEmpty()) { + return Title::whereIn('id', $titleIds) + ->with(['primaryVideo']) + ->when(isset($params['compact']), fn($q) => $q->compact()) + ->get(); + } + + return collect(); + } + + private function getByTags(Title $title, array $params): Collection + { + $prefix = DB::getTablePrefix(); + $keywordIds = $title->keywords->pluck('id'); + $genreIds = $title->genres->pluck('id'); + + return DB::table('titles') + ->join('keyword_title as k', 'k.title_id', '=', 'titles.id') + ->join('genre_title as g', 'g.title_id', '=', 'titles.id') + ->select( + DB::raw( + "{$prefix}titles.id, COUNT({$prefix}k.id) + COUNT({$prefix}g.id) as total_count", + ), + ) + ->whereIn('k.keyword_id', $keywordIds) + ->whereIn('g.genre_id', $genreIds) + ->when($title->release_date, function ($q) use ($title) { + $q->whereBetween('release_date', [ + $title->release_date->subYears(5)->format('Y-m-d'), + $title->release_date->addYears(5)->format('Y-m-d'), + ]); + }) + ->where('titles.id', '!=', $title->id) + ->groupBy('titles.id') + ->orderBy('total_count', 'desc') + ->limit(Arr::get($params, 'limit', 10)) + ->pluck('titles.id'); + } +} diff --git a/app/Actions/Titles/Retrieve/PaginateSeasonEpisodes.php b/app/Actions/Titles/Retrieve/PaginateSeasonEpisodes.php new file mode 100755 index 0000000..a8835b3 --- /dev/null +++ b/app/Actions/Titles/Retrieve/PaginateSeasonEpisodes.php @@ -0,0 +1,40 @@ +episodes()->where('season_number', $seasonNumber); + $builder->with(['primaryVideo']); + + $orderBy = Arr::get($params, 'orderBy', 'episode_number'); + $orderDir = Arr::get($params, 'orderDir', 'asc'); + + $pagination = $builder + ->orderBy($orderBy, $orderDir) + ->simplePaginate(Arr::get($params, 'perPage', 30)); + + // only show first paragraph of description + $pagination->through(function ($episode) use ($params) { + $episode->description = explode("\n", $episode->description)[0]; + if (Arr::get($params, 'excludeDescription')) { + $episode->description = null; + } elseif (Arr::get($params, 'truncateDescriptions')) { + $episode->description = Str::limit($episode->description, 200); + } + return $episode; + }); + + return $pagination; + } +} diff --git a/app/Actions/Titles/Retrieve/PaginateTitleSeasons.php b/app/Actions/Titles/Retrieve/PaginateTitleSeasons.php new file mode 100755 index 0000000..678b989 --- /dev/null +++ b/app/Actions/Titles/Retrieve/PaginateTitleSeasons.php @@ -0,0 +1,26 @@ +seasons() + ->select([ + 'seasons.id', + 'seasons.poster', + 'seasons.release_date', + 'number', + 'title_id', + ]) + ->withCount('episodes') + ->orderBy('number', 'desc') + ->paginate(Arr::get($params, 'perPage', 8)); + } +} diff --git a/app/Actions/Titles/Retrieve/PaginateTitles.php b/app/Actions/Titles/Retrieve/PaginateTitles.php new file mode 100755 index 0000000..b00c024 --- /dev/null +++ b/app/Actions/Titles/Retrieve/PaginateTitles.php @@ -0,0 +1,54 @@ +where('is_series', $type === 'series'); + } + + $builder->with(['primaryVideo']); + + $datasource = new Datasource($builder, $params); + + // prevent duplicate items when ordering by columns that are not + // guaranteed to be unique (budget, popularity , revenue etc.) + $datasource->secondaryOrderCol = 'id'; + + // only show titles with more than 50 votes when filtering by rating + if ($datasource->filters->has('tmdb_vote_average')) { + $builder->where('tmdb_vote_count', '>=', 50); + } + + $order = $datasource->getOrder(); + + $order['col'] = str_replace( + ['user_score', 'rating'], + config('common.site.rating_column'), + $order['col'], + ); + + // show titles with less than 50 votes on tmdb last, regardless of their average + if (Str::contains($order['col'], 'tmdb_vote_average')) { + $builder->orderBy(DB::raw('tmdb_vote_count > 100'), 'desc'); + } + + $datasource->order = $order; + + return $datasource->paginate(); + } +} diff --git a/app/Actions/Titles/Retrieve/ShowTitle.php b/app/Actions/Titles/Retrieve/ShowTitle.php new file mode 100755 index 0000000..f5ca6cf --- /dev/null +++ b/app/Actions/Titles/Retrieve/ShowTitle.php @@ -0,0 +1,55 @@ +maybeUpdateFromExternal(); + if (!$title) { + abort(404); + } + } + + $response = ['title' => $title->loadCount('seasons')]; + + foreach (explode(',', Arr::get($params, 'load', '')) as $relation) { + $methodName = sprintf('load%s', Str::camel($relation)); + if (method_exists($this, $methodName)) { + $response = $this->$methodName($title, $params, $response); + } elseif (method_exists($title, $relation)) { + $title->load($relation); + } + } + + foreach ( + explode(',', Arr::get($params, 'loadCount', '')) + as $relation + ) { + if (method_exists($title, $relation)) { + $title->loadCount($relation); + } + } + + return $response; + } +} diff --git a/app/Actions/Titles/Store/StoreCredits.php b/app/Actions/Titles/Store/StoreCredits.php new file mode 100755 index 0000000..e96bc51 --- /dev/null +++ b/app/Actions/Titles/Store/StoreCredits.php @@ -0,0 +1,209 @@ +model = $model; + + if (empty($originalMediaItems)) return; + + // generate records we will insert into "creditables" pivot table + $newPivotRecords = $this->generatePivotRecords($originalMediaItems); + if ($newPivotRecords->isEmpty()) return; + + // fetch all existing "creditable" pivot rows for this creditable + $existingPivotRecords = $this->getExistingRecords($newPivotRecords); + + // merge new and existing pivot records, so "order" column value is not lost + $mergedPivotRecords = $this->mergeNewAndExistingRecords($existingPivotRecords, $newPivotRecords); + + // delete all "creditables" pivot table records for this creditable + $this->detachExistingRecords($existingPivotRecords); + + // insert new pivot records + DB::table('creditables')->insert($mergedPivotRecords->toArray()); + } + + /** + * @param array $originalMediaItems + * @return Collection + */ + private function generatePivotRecords($originalMediaItems) + { + // need to remove series, otherwise it will attach all guest starts to main series, because + // tmdb API does not specify whether credit belongs to series, season or episode. + $originalMediaItems = $this->filterOutSeries($originalMediaItems); + $dbMediaItems = $this->insertOrRetrieveMediaItems($originalMediaItems); + + return collect($originalMediaItems)->map(function($originalMediaItem) use($dbMediaItems) { + $creditable = $dbMediaItems->first(fn($item, $uniqueKey) => $uniqueKey === $originalMediaItem[self::UNIQUE_KEY] && $item['is_series'] === Arr::get($originalMediaItem, 'is_series')); + + if ( ! $creditable) return null; + + // either attaching multiple titles to a person + // or attaching multiple people to title/season/episode + if ($this->model->model_type === Person::MODEL_TYPE) { + $personId = $this->model->id; + $creditableId = $creditable->id; + } else { + $personId = $creditable->id; + $creditableId = $this->model->id; + } + + // build relation records for attaching all credits to title + // (same person might be attached to title multiple times) + return [ + 'id' => null, + 'person_id' => $personId, + 'creditable_id' => $creditableId, + 'creditable_type' => $this->getCreditableType(), + 'character' => Arr::get($originalMediaItem, 'relation_data.character'), + 'order' => Arr::get($originalMediaItem, 'relation_data.order'), + 'department' => Arr::get($originalMediaItem, 'relation_data.department'), + 'job' => Arr::get($originalMediaItem, 'relation_data.job'), + ]; + })->filter()->values(); + } + + /** + * Merge existing "creditables" pivot table records with the ones + * we are about to insert. This needs to be done because sometimes + * "order" property does not exist on "new" records, but exists on + * "old" ones and since we will delete "old" ones, "order" would be lost. + * + * @param Collection $existingRecords + * @param Collection $newRecords + * @return Collection + */ + private function mergeNewAndExistingRecords($existingRecords, $newRecords) + { + // all of these properties on both arrays must match for them to be considered equal + $matchProps = collect(['person_id', 'creditable_id', 'creditable_type', 'character', 'department', 'job']); + + return $newRecords->map(function($newRecord) use($existingRecords, $matchProps) { + $oldRecord = $existingRecords->first(fn($existingRecord) => $matchProps->every(fn($prop) => $existingRecord[$prop] === $newRecord[$prop]), []); + return $this->arrayMergeIfNotNull($newRecord, $oldRecord); + }); + } + + /** + * @param array $arr1 + * @param array $arr2 + * @return array + */ + private function arrayMergeIfNotNull($arr1, $arr2) { + foreach($arr2 as $key => $val) { + $is_set_and_not_null = isset($arr1[$key]); + if ( $val == null && $is_set_and_not_null ) { + $arr2[$key] = $arr1[$key]; + } + } + return array_merge($arr1, $arr2); + } + + /** + * Insert or retrieve titles or people that need to be attached. + * + * @param Collection|(Title|Person)[] $mediaItems + * @return Collection + */ + private function insertOrRetrieveMediaItems(Collection|array $mediaItems) + { + // make sure we only insert person/title once, even + // if they appear multiple time in title credits + $uniqueMediaItems = collect($mediaItems)->unique(self::UNIQUE_KEY)->values(); + + if ($uniqueMediaItems->isEmpty()) return collect(); + + if ($uniqueMediaItems[0]['model_type'] === Person::MODEL_TYPE) { + $mediaItems = $this->person->insertOrRetrieve($uniqueMediaItems); + } else { + $mediaItems = $this->title->insertOrRetrieve($uniqueMediaItems); + } + + return $mediaItems->mapWithKeys(fn($item) => [$item[self::UNIQUE_KEY] => $item]); + } + + /** + * @param Collection $records + * @return Collection + */ + private function getExistingRecords($records) + { + // select only fields needed to do the diff + return DB::table('creditables') + ->whereIn('person_id', $records->pluck('person_id')) + ->whereIn('job', $records->pluck('job')) + ->where('creditable_type', $records[0]['creditable_type']) + ->whereIn('creditable_id', $records->pluck('creditable_id')) + ->get() + ->map(fn($record) => (array) $record); + } + + /** + * Delete creditable records that already exist. + * + * @param Collection $records + */ + private function detachExistingRecords($records) + { + if ($records->isEmpty()) return; + + // select only fields needed to do the diff + $existingRecords = DB::table('creditables') + ->whereIn('person_id', $records->pluck('person_id')) + ->whereIn('job', $records->pluck('job')) + ->where('creditable_type', $records[0]['creditable_type']) + ->whereIn('creditable_id', $records->pluck('creditable_id')) + ->get(); + + DB::table('creditables')->whereIn('id', $existingRecords->pluck('id'))->delete(); + } + + /** + * Get creditable type for morphToMany or morphedByMany relation. + */ + private function getCreditableType(): string + { + if ($this->model::class === Person::class) { + // won't be attaching seasons or episodes here + // so can just return title type instantly + return Title::MODEL_TYPE; + } else { + return $this->model::MODEL_TYPE; + } + } + + /** + * Remove all series and episodes from specified array. + * + * @param array $originalMediaItems + * @return array + */ + private function filterOutSeries($originalMediaItems) + { + return array_filter($originalMediaItems, fn($creditable) => !Arr::get($creditable, 'is_series')); + } +} diff --git a/app/Actions/Titles/Store/StoreEpisodeData.php b/app/Actions/Titles/Store/StoreEpisodeData.php new file mode 100755 index 0000000..d11dde3 --- /dev/null +++ b/app/Actions/Titles/Store/StoreEpisodeData.php @@ -0,0 +1,51 @@ +episodes()->get(); + foreach ($episodes as $episodeData) { + $episode = $this->storePrimaryData( + $season, + $existingEpisodes, + $episodeData, + ); + app(StoreCredits::class)->execute($episode, $episodeData['cast']); + } + + return $title; + } + + private function storePrimaryData( + Season $season, + Collection $existingEpisodes, + array $episodeData, + ): Episode { + $episodeData = array_filter( + $episodeData, + fn($value) => !is_array($value) && $value !== Episode::MODEL_TYPE, + ); + $episodeData['title_id'] = $season->title_id; + unset($episodeData['id']); + + $existingEpisode = $existingEpisodes->firstWhere( + 'episode_number', + $episodeData['episode_number'], + ); + + if ($existingEpisode) { + $existingEpisode->update($episodeData); + return $existingEpisode; + } else { + return $season->episodes()->create($episodeData); + } + } +} diff --git a/app/Actions/Titles/Store/StoreSeasonData.php b/app/Actions/Titles/Store/StoreSeasonData.php new file mode 100755 index 0000000..d13ef6a --- /dev/null +++ b/app/Actions/Titles/Store/StoreSeasonData.php @@ -0,0 +1,56 @@ +title = $title; + + $season = $this->persistData($data); + if (isset($data['cast'])) { + app(StoreCredits::class)->execute($season, $data['cast']); + } + + if (isset($data['episodes'])) { + app(StoreEpisodeData::class)->execute( + $title, + $season, + $data['episodes'], + ); + } + + return $this->title; + } + + private function persistData(array $data): Season + { + // remove all relation data + $data = array_filter( + $data, + fn($value) => !is_array($value) && $value !== Season::MODEL_TYPE, + ); + + // if season data did not change then timestamps + // will not be updated because model is not dirty + $data['updated_at'] = now(); + + return Season::updateOrCreate( + [ + 'title_id' => $this->title->id, + 'number' => $data['number'], + ], + $data, + ); + } +} diff --git a/app/Actions/Titles/Store/StoreTitleData.php b/app/Actions/Titles/Store/StoreTitleData.php new file mode 100755 index 0000000..7720439 --- /dev/null +++ b/app/Actions/Titles/Store/StoreTitleData.php @@ -0,0 +1,141 @@ +title = $title; + $this->data = $data; + $this->options = $options; + + $this->persistData(); + $this->persistRelations(); + + return $this->title; + } + + private function persistData(): void + { + $titleData = array_filter( + $this->data, + fn( + $value, // make sure we don't overwrite existing values with null + ) => !is_array($value) && + ($this->options['overrideWithEmptyValues'] ?? !is_null($value)), + ); + + $this->title->fill($titleData)->save(); + } + + private function persistRelations(): void + { + $relations = array_filter($this->data, fn($value) => is_array($value)); + + foreach ($relations as $name => $values) { + switch ($name) { + case 'videos': + $this->persistVideos($values); + break; + case 'images': + $this->storeImages($values, $this->title); + break; + case 'genres': + $this->persistTags($values, 'genre'); + break; + case 'countries': + $this->persistTags($values, 'production_country'); + break; + case 'cast': + app(StoreCredits::class)->execute($this->title, $values); + break; + case 'keywords': + $this->persistTags($values, 'keyword'); + break; + case 'seasons': + $this->persistSeasons($values); + } + } + } + + private function persistSeasons(array $seasons): void + { + $newSeasons = collect($seasons) + ->map(function ($season) { + $season['title_id'] = $this->title->id; + return $season; + }) + ->filter( + fn($season) => !$this->title->seasons->contains( + 'number', + $season['number'], + ), + ); + + if ($newSeasons->isNotEmpty()) { + Season::insert($newSeasons->toArray()); + } + } + + private function persistTags(array $tags, string $type): void + { + $values = collect($tags)->map( + fn($tag) => [ + 'name' => $tag['name'], + 'display_name' => Arr::get( + $tag, + 'display_name', + ucfirst($tag['name']), + ), + ], + ); + + $tags = app(modelTypeToNamespace($type))->insertOrRetrieve( + $values, + $type, + ); + + $relation = $this->title->{Str::camel(Str::plural($type))}(); + $relation->syncWithoutDetaching($tags->pluck('id')); + } + + private function persistVideos(array $values): void + { + $videos = collect($values) + ->unique(fn($v) => strtolower($v['name'])) + ->values() + ->map(function ($value, $i) { + $value['title_id'] = $this->title->id; + $value['order'] = $i + 1; + $value['created_at'] = now(); + $value['updated_at'] = now(); + return $value; + }); + + Video::where('origin', '!=', 'local') + ->where('title_id', $this->title->id) + ->whereNull('episode_num') + ->delete(); + + Video::insert($videos->toArray()); + } +} diff --git a/app/Actions/Titles/StoreMediaImageOnDisk.php b/app/Actions/Titles/StoreMediaImageOnDisk.php new file mode 100755 index 0000000..9443fa3 --- /dev/null +++ b/app/Actions/Titles/StoreMediaImageOnDisk.php @@ -0,0 +1,47 @@ + null, + 'large' => 500, + 'medium' => 300, + 'small' => 92, + ]; + + public function execute(UploadedFile $file): string + { + $hash = Str::random(30); + + $manager = new ImageManager(new Driver()); + $img = $manager->read($file); + + $extension = $file->extension() ?? 'jpeg'; + + foreach ($this->sizes as $key => $size) { + if ($size) { + $img->scale($size); + } + + Storage::disk('public')->put( + "media-images/backdrops/$hash/$key.$extension", + $extension === 'png' ? $img->toPng() : $img->toJpeg(), + ); + } + + $endpoint = config('common.site.file_preview_endpoint'); + $uri = "media-images/backdrops/$hash/original.$extension"; + return $endpoint + ? "$endpoint/storage/$uri" + : Storage::disk('public')->url($uri); + } +} diff --git a/app/Actions/Titles/StoresMediaImages.php b/app/Actions/Titles/StoresMediaImages.php new file mode 100755 index 0000000..ea21fd2 --- /dev/null +++ b/app/Actions/Titles/StoresMediaImages.php @@ -0,0 +1,24 @@ +id; + $value['model_type'] = $model->getMorphClass(); + return $value; + }, $values); + + $model + ->images() + ->where('source', '!=', 'local') + ->delete(); + $model->images()->insert($values); + } +} diff --git a/app/Actions/Titles/TitleCredits.php b/app/Actions/Titles/TitleCredits.php new file mode 100755 index 0000000..838c736 --- /dev/null +++ b/app/Actions/Titles/TitleCredits.php @@ -0,0 +1,164 @@ +getBaseQuery($title, $season, $episode) + ->whereIn('creditables.department', [ + 'actors', + 'writing', + 'directing', + 'creators', + ]) + ->limit(50); + + $credits = $this->groupByDepartment($builder); + + if (isset($credits['actors'])) { + $credits['actors'] = $credits['actors']->take(15)->values(); + } + if (isset($credits['writing'])) { + $credits['writing'] = $credits['writing']->take(3)->values(); + } + if (isset($credits['directing'])) { + $credits['directing'] = $credits['directing'] + ->filter(fn($c) => $c['pivot']['job'] === 'director') + ->take(3) + ->values(); + } + if (isset($credits['creators'])) { + $credits['creators'] = $credits['creators']->take(3)->values(); + } + + return $credits; + } + + public function loadFull( + Title $title, + Season $season = null, + Episode $episode = null, + ): Collection { + $builder = $this->getBaseQuery($title, $season, $episode); + return $this->groupByDepartment($builder); + } + + protected function getBaseQuery( + Title $title = null, + Season $season = null, + Episode $episode = null, + ): Builder { + $prefix = DB::getTablePrefix(); + $builder = Person::join( + 'creditables', + 'people.id', + '=', + 'creditables.person_id', + ) + ->select([ + 'people.id', + 'people.name', + 'people.poster', + DB::raw("{$prefix}creditables.id as pivot_id"), + DB::raw( + "{$prefix}creditables.creditable_id as pivot_creditable_id", + ), + DB::raw( + "{$prefix}creditables.creditable_type as pivot_creditable_type", + ), + DB::raw("{$prefix}creditables.job as pivot_job"), + DB::raw("{$prefix}creditables.department as pivot_department"), + DB::raw("{$prefix}creditables.order as pivot_order"), + DB::raw("{$prefix}creditables.character as pivot_character"), + ]) + // Need to keep same person in different departments (as actor and producer for example) + // while still removing duplicates when loading credits for title, season and episode + ->groupBy(['people.id', 'creditables.department']); + + $builder->where(function ($q) use ($title, $season, $episode) { + if ($title) { + $q->orWhere( + fn($q) => $q + ->where('creditables.creditable_id', $title->id) + ->where( + 'creditables.creditable_type', + $title->getMorphClass(), + ), + ); + } + + if ($episode) { + $q->orWhere( + fn($q) => $q + ->where('creditables.creditable_id', $episode->id) + ->where( + 'creditables.creditable_type', + $episode->getMorphClass(), + ), + ); + } + + if ($season) { + $q->orWhere( + fn($q) => $q + ->where('creditables.creditable_id', $season->id) + ->where( + 'creditables.creditable_type', + $season->getMorphClass(), + ), + ); + } + }); + + // order by department first, so we always get director, + // writers and creators, even if limit is applied to this query + $prefix = DB::getTablePrefix(); + return $builder + ->orderBy( + DB::raw( + "FIELD(department, 'directing', 'creators', 'writing', 'actors')", + ), + ) + // should be "desc" and not "asc" because "minus" is added which will reverse order + ->orderBy(DB::raw("-{$prefix}creditables.order"), 'desc'); + } + + protected function groupByDepartment(Builder $builder): Collection + { + $credits = $builder->get()->map(function ($credit) { + $credit->pivot = [ + 'id' => $credit->pivot_id, + 'job' => $credit->pivot_job, + 'department' => $credit->pivot_department, + 'character' => $credit->pivot_character, + ]; + + unset( + $credit->pivot_id, + $credit->pivot_creditable_id, + $credit->pivot_creditable_type, + $credit->pivot_job, + $credit->pivot_department, + $credit->pivot_order, + $credit->pivot_character, + ); + + return $credit; + }); + + return $credits->groupBy('pivot.department'); + } +} diff --git a/app/Actions/Videos/CrupdateVideo.php b/app/Actions/Videos/CrupdateVideo.php new file mode 100755 index 0000000..39862de --- /dev/null +++ b/app/Actions/Videos/CrupdateVideo.php @@ -0,0 +1,79 @@ +where('episode_number', $params['episode_num']) + ->where('season_number', $params['season_num']) + ->firstOrFail(); + $params['episode_id'] = $episode->id; + } + + $params['positive_votes'] = 0; + $params['negative_votes'] = 0; + $params['origin'] = 'local'; + + $captions = Arr::pull($params, 'captions'); + + if ($videoId) { + $video = Video::findOrFail($videoId); + $video->fill($params)->save(); + } else { + $params['approved'] = $this->shouldAutoApprove(); + $params['user_id'] = Auth::id(); + $video = Video::create($params); + } + + if (isset($captions)) { + $this->syncCaptions($video, $captions); + } + + return $video; + } + + private function shouldAutoApprove(): bool + { + return settings('streaming.auto_approve') || + Auth::user()->hasPermission('admin'); + } + + protected function syncCaptions(Video $video, array $captions): void + { + $captions = collect($captions); + + // delete captions that were removed + $captionIds = $captions->pluck('id')->filter(); + $video + ->captions() + ->whereNotIn('id', $captionIds) + ->delete(); + + $captions->each(function ($caption, $index) use ($video) { + if (isset($caption['id'])) { + $video + ->captions() + ->where('id', $caption['id']) + ->update([ + 'name' => $caption['name'], + 'language' => $caption['language'], + 'url' => $caption['url'], + 'order' => $index, + ]); + } else { + $caption['user_id'] = Auth::id(); + $caption['order'] = $index; + $video->captions()->create($caption); + } + }); + } +} diff --git a/app/Console/Commands/CleanDemoSite.php b/app/Console/Commands/CleanDemoSite.php new file mode 100755 index 0000000..96f69d1 --- /dev/null +++ b/app/Console/Commands/CleanDemoSite.php @@ -0,0 +1,61 @@ +cleanAdminUser('admin@admin.com'); + + // delete localizations + Localization::get()->each(function (Localization $localization) { + if (strtolower($localization->name) !== 'english') { + $localization->delete(); + } + }); + + // delete custom pages + CustomPage::truncate(); + + app(DefaultPagesSeeder::class)->run(); + } + + private function cleanAdminUser($email): void + { + $admin = User::where('email', $email)->first(); + + if (!$admin) { + $admin = User::create([ + 'email' => $email, + ]); + } + + $admin->avatar = null; + $admin->username = 'admin'; + $admin->first_name = 'Demo'; + $admin->last_name = 'Admin'; + $admin->password = 'admin'; + $admin->email_verified_at = now()->subDays(10); + $admin->save(); + + $adminPermission = app(Permission::class) + ->where('name', 'admin') + ->first(); + $this->syncPermissions($admin, [$adminPermission]); + } +} diff --git a/app/Console/Commands/GenerateDemoDataCommand.php b/app/Console/Commands/GenerateDemoDataCommand.php new file mode 100755 index 0000000..7aabd40 --- /dev/null +++ b/app/Console/Commands/GenerateDemoDataCommand.php @@ -0,0 +1,269 @@ +argument('variant'); + + if ($variant === 'database') { + $this->databaseVariant(); + } elseif ($variant === 'streaming') { + $this->streamingVariant(); + } elseif ($variant === 'anime') { + $this->animeVariant(); + } else { + $this->error('Invalid variant'); + } + + $this->info('Demo data generated', true); + + Artisan::call('up'); + } + + protected function databaseVariant(): void + { + $this->overridePrimaryMenu([ + [ + 'id' => 'cVKgbI', + 'type' => 'channels', + 'label' => 'Movies', + 'action' => '/movies', + ], + [ + 'id' => 'nVKg4v', + 'type' => 'channels', + 'label' => 'Series', + 'action' => '/series', + ], + [ + 'id' => 'nVKg1Ix', + 'type' => 'channels', + 'label' => 'People', + 'action' => '/people', + ], + [ + 'id' => 'nVKxdI', + 'type' => 'channels', + 'label' => 'News', + 'action' => '/latest-news', + ], + ]); + + $homepageChannel = $this->generateChannels([ + resource_path('defaults/channels/shared-channels.json'), + resource_path('defaults/channels/default-channels.json'), + ]); + settings()->save([ + 'homepage.type' => 'channels', + 'homepage.value' => $homepageChannel->id, + 'streaming.video_panel_content' => 'all', + 'streaming.prefer_full' => false, + 'streaming.show_video_selector' => false, + 'streaming.show_header_play' => true, + 'content.search_provider' => 'tmdb', + 'content.title_provider' => 'tmdb', + 'content.people_provider' => 'tmdb', + 'content.automate_filmography' => true, + 'title_page.sections' => json_encode([ + 'seasons', + 'videos', + 'images', + 'reviews', + 'cast', + 'related', + ]), + ]); + + Artisan::call(UpdateAllChannelsContent::class); + + (new GenerateDemoUsers())->execute(); + (new GenerateDemoVideoVotes())->execute(); + (new GenerateDemoComments())->execute(); + (new GenerateDemoReviews())->execute(); + } + + protected function streamingVariant(): void + { + if ($this->option('truncate')) { + Artisan::call(TruncateTitleData::class); + } + + $this->overridePrimaryMenu([ + [ + 'id' => 'cVKg0I', + 'type' => 'route', + 'label' => 'Movies', + 'action' => '/movies', + ], + [ + 'id' => 'nVKg0v', + 'type' => 'route', + 'label' => 'TV shows', + 'action' => '/series', + ], + [ + 'id' => 'nVKg0Ix', + 'type' => 'route', + 'label' => 'Watchlist', + 'action' => '/watchlist', + ], + ]); + + $homepageChannel = $this->generateChannels([ + resource_path('defaults/channels/shared-channels.json'), + resource_path('defaults/channels/streaming-channels.json'), + ]); + + $darkTheme = CssTheme::where('default_dark', true)->first(); + + settings()->save([ + 'themes.default_id' => $darkTheme->id, + 'homepage.type' => 'landingPage', + 'homepage.value' => $homepageChannel->id, + 'streaming.video_panel_content' => 'full', + 'streaming.prefer_full' => true, + 'streaming.show_video_selector' => false, + 'streaming.show_header_play' => true, + 'content.search_provider' => 'local', + 'content.title_provider' => 'tmdb', + 'content.people_provider' => 'tmdb', + 'content.automate_filmography' => false, + 'title_page.sections' => json_encode([ + 'seasons', + 'images', + 'reviews', + 'cast', + 'related', + ]), + ]); + + app(DotEnvEditor::class)->write([ + 'scout_driver' => 'meilisearch', + ]); + + $this->info('Updating channels'); + Artisan::call(UpdateAllChannelsContent::class); + $this->info('Channels updated.'); + $this->info('Updating seasons'); + Artisan::call(UpdateSeasonsFromRemote::class); + $this->info('Seasons updated.'); + + (new GenerateDemoUsers())->execute(); + (new GenerateDemoStreamVideos())->execute(); + (new GenerateDemoVideoVotes())->execute(); + (new GenerateDemoComments())->execute(); + (new GenerateDemoReviews())->execute(); + } + + protected function animeVariant(): void + { + if ($this->option('truncate')) { + Artisan::call(TruncateTitleData::class); + } + + $this->overridePrimaryMenu([ + [ + 'id' => 'cVKg0I', + 'type' => 'route', + 'label' => 'Movies', + 'action' => '/movies', + ], + [ + 'id' => 'nVKg0v', + 'type' => 'route', + 'label' => 'TV shows', + 'action' => '/series', + ], + [ + 'id' => 'nVKg0Ix', + 'type' => 'route', + 'label' => 'Watchlist', + 'action' => '/watchlist', + ], + ]); + + $homepageChannel = $this->generateChannels([ + resource_path('defaults/channels/shared-channels.json'), + resource_path('defaults/channels/anime-channels.json'), + ]); + + settings()->save([ + 'homepage.type' => 'channels', + 'homepage.value' => $homepageChannel->id, + 'streaming.video_panel_content' => 'full', + 'streaming.prefer_full' => true, + 'streaming.show_video_selector' => true, + 'streaming.show_header_play' => true, + 'content.search_provider' => 'local', + 'content.title_provider' => 'tmdb', + 'content.people_provider' => 'tmdb', + 'content.automate_filmography' => false, + 'title_page.sections' => json_encode([ + 'episodes', + 'images', + 'reviews', + 'cast', + 'related', + ]), + ]); + + app(DotEnvEditor::class)->write([ + 'scout_driver' => 'meilisearch', + ]); + + Artisan::call(UpdateAllChannelsContent::class); + + (new GenerateDemoUsers())->execute(); + (new GenerateDemoAnimeVideos())->execute(); + (new GenerateDemoVideoVotes())->execute(); + (new GenerateDemoComments())->execute('anime'); + (new GenerateDemoReviews())->execute(); + } + + protected function overridePrimaryMenu(array $items): void + { + $menus = Setting::where('name', 'menus')->first()->value; + $index = array_search('primary', array_column($menus, 'name')); + $menus[$index]['items'] = $items; + Setting::where('name', 'menus')->update([ + 'value' => json_encode($menus), + ]); + } + + protected function generateChannels(array $paths): ?Channel + { + $ids = Channel::where('type', 'channel')->pluck('id'); + DB::table('channelables') + ->whereIn('channel_id', $ids) + ->delete(); + Channel::whereIn('id', $ids)->delete(); + + return (new GenerateChannelsFromConfig())->execute($paths); + } +} diff --git a/app/Console/Commands/GenerateSitemap.php b/app/Console/Commands/GenerateSitemap.php new file mode 100755 index 0000000..394cbdf --- /dev/null +++ b/app/Console/Commands/GenerateSitemap.php @@ -0,0 +1,18 @@ +generate(); + } +} diff --git a/app/Console/Commands/SyncTmdbValueLists.php b/app/Console/Commands/SyncTmdbValueLists.php new file mode 100755 index 0000000..4ec32a1 --- /dev/null +++ b/app/Console/Commands/SyncTmdbValueLists.php @@ -0,0 +1,103 @@ +syncGenres(); + $this->syncCountries(); + $this->syncDepartments(); + $this->syncLanguages(); + $this->syncKeywords(); + + return Command::SUCCESS; + } + + protected function syncKeywords(): void + { + $downloadUrl = + 'http://files.tmdb.org/p/exports/keyword_ids_07_08_2023.json.gz'; + $content = gzdecode(file_get_contents($downloadUrl)); + $content = str_replace('"}', '"},', $content); + $content = rtrim(trim($content), ','); + file_put_contents(resource_path('lists/tmdb-keywords.json'), '[' . $content . ']'); + } + + protected function syncGenres(): void + { + $movieGenres = Http::get(TmdbApi::TMDB_BASE . 'genre/movie/list', [ + 'api_key' => config('services.tmdb.key'), + 'language' => 'en-US', + ])->json()['genres']; + + $tvGenres = Http::get(TmdbApi::TMDB_BASE . 'genre/tv/list', [ + 'api_key' => config('services.tmdb.key'), + 'language' => 'en-US', + ])->json()['genres']; + + $mergedGenres = collect($movieGenres) + ->merge($tvGenres) + ->unique('id') + ->values() + ->toArray(); + + file_put_contents( + resource_path('lists/tmdb-genres.json'), + json_encode($mergedGenres), + ); + } + + private function syncLanguages(): void + { + $languages = Http::get(TmdbApi::TMDB_BASE . 'configuration/languages', [ + 'api_key' => config('services.tmdb.key'), + ])->json(); + + $languages = array_map(fn($language) => [ + 'code' => $language['iso_639_1'], + 'name' => $language['english_name'], + ], $languages); + + file_put_contents( + resource_path('lists/tmdb-languages.json'), + json_encode($languages), + ); + } + + protected function syncDepartments(): void + { + $departments = Http::get(TmdbApi::TMDB_BASE . 'configuration/jobs', [ + 'api_key' => config('services.tmdb.key'), + ])->json(); + + file_put_contents( + resource_path('lists/tmdb-departments.json'), + json_encode($departments), + ); + } + + protected function syncCountries(): void + { + $countries = Http::get(TmdbApi::TMDB_BASE . 'configuration/countries', [ + 'api_key' => config('services.tmdb.key'), + ])->json(); + + $countries = array_map(fn($country) => [ + 'code' => $country['iso_3166_1'], + 'name' => $country['english_name'], + ], $countries); + + file_put_contents( + resource_path('lists/tmdb-countries.json'), + json_encode($countries), + ); + } +} diff --git a/app/Console/Commands/TruncateTitleData.php b/app/Console/Commands/TruncateTitleData.php new file mode 100755 index 0000000..89bb6e9 --- /dev/null +++ b/app/Console/Commands/TruncateTitleData.php @@ -0,0 +1,45 @@ +truncate(); + DB::table('channels')->truncate(); + DB::table('comment_reports')->truncate(); + DB::table('comment_votes')->truncate(); + DB::table('comments')->truncate(); + DB::table('country_title')->truncate(); + DB::table('creditables')->truncate(); + DB::table('episodes')->truncate(); + DB::table('genre_title')->truncate(); + DB::table('genres')->truncate(); + DB::table('images')->truncate(); + DB::table('keyword_title')->truncate(); + DB::table('keywords')->truncate(); + DB::table('production_countries')->truncate(); + DB::table('review_feedback')->truncate(); + DB::table('review_reports')->truncate(); + DB::table('reviews')->truncate(); + DB::table('seasons')->truncate(); + DB::table('tags')->truncate(); + DB::table('taggables')->truncate(); + DB::table('people')->truncate(); + DB::table('titles')->truncate(); + DB::table('videos')->truncate(); + DB::table('video_captions')->truncate(); + DB::table('video_reports')->truncate(); + DB::table('video_plays')->truncate(); + DB::table('video_votes')->truncate(); + DB::table('news_article_models')->truncate(); + } +} diff --git a/app/Console/Commands/UpdateNewsFromRemote.php b/app/Console/Commands/UpdateNewsFromRemote.php new file mode 100755 index 0000000..cdf14a9 --- /dev/null +++ b/app/Console/Commands/UpdateNewsFromRemote.php @@ -0,0 +1,20 @@ +execute(); + + $this->info('News updated.'); + } +} diff --git a/app/Console/Commands/UpdateSeasonsFromRemote.php b/app/Console/Commands/UpdateSeasonsFromRemote.php new file mode 100755 index 0000000..39a4ad8 --- /dev/null +++ b/app/Console/Commands/UpdateSeasonsFromRemote.php @@ -0,0 +1,27 @@ +with('title') + ->limit(50) + ->get(); + + $this->withProgressBar($seasons, function (Season $season) { + if ($season->title) { + $season->maybeUpdateFromExternal($season->title); + } + }); + + $this->info('Seasons updated'); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php new file mode 100755 index 0000000..8b0c753 --- /dev/null +++ b/app/Console/Kernel.php @@ -0,0 +1,45 @@ +command(UpdateNewsFromRemote::class)->daily(); + } + + if ( + config('services.tmdb.key') && + (settings('content.force_season_update') || + settings('content.title_provider') === 'tmdb') + ) { + $schedule + ->command(UpdateSeasonsFromRemote::class) + ->everyFourHours(); + } + + if (config('common.site.demo')) { + $schedule->command(CleanDemoSite::class)->daily(); + } + + $schedule->command(UpdateAllChannelsContent::class)->daily(); + } + + protected function commands(): void + { + $this->load(__DIR__ . '/Commands'); + + require base_path('routes/console.php'); + } +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php new file mode 100755 index 0000000..dec5214 --- /dev/null +++ b/app/Exceptions/Handler.php @@ -0,0 +1,10 @@ +authorize('index', Title::class); + + $builder = app(modelTypeToNamespace($tagType))->newQuery(); + + $dataSource = new Datasource($builder, request()->all()); + + $pagination = $dataSource->paginate(); + + return $this->success(['pagination' => $pagination]); + } + + public function store(string $type) { + $this->authorize('store', Title::class); + + $data = $this->validate(request(), [ + 'name' => 'required|string', + 'display_name' => 'string', + ]); + + $tag = app(modelTypeToNamespace($type))->create($data); + + return $this->success(['tag' => $tag]); + } + + public function update(string $type, int $tagId) { + $this->authorize('update', Title::class); + + $data = $this->validate(request(), [ + 'name' => 'string', + 'display_name' => 'string', + ]); + + $tag = app(modelTypeToNamespace($type))->findOrFail($tagId); + + $tag->update($data); + + return $this->success(['tag' => $tag]); + } + + public function destroy($type, string $ids) + { + $tagIds = explode(',', $ids); + $this->authorize('destroy', Title::class); + + foreach ($tagIds as $tagId) { + $tag = app(modelTypeToNamespace($type))->findOrFail($tagId); + $tag->titles()->detach(); + $tag->delete(); + } + + return $this->success(); + } +} diff --git a/app/Http/Controllers/ApiTmdbController.php b/app/Http/Controllers/ApiTmdbController.php new file mode 100755 index 0000000..28e913f --- /dev/null +++ b/app/Http/Controllers/ApiTmdbController.php @@ -0,0 +1,297 @@ + null, + 'large' => 500, + 'medium' => 300, + 'small' => 92, + ]; + + public function index() + { + $movieId = request('movieId'); + //$movieId = 1284120; + + /****************************************** */ + $responseMovie = Http::withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json', + ])->get("$this->baseUrl/movie/$movieId", [ + 'language' => 'fr-FR' + ]); + + $movie = $responseMovie->successful() ? $responseMovie->json() : []; + + + $title = Title::create([ + 'imdb_id' => $movie['id'], + 'backdrop' => "https://image.tmdb.org/t/p/original".$movie['backdrop_path'], + 'poster' => "https://image.tmdb.org/t/p/original".$movie['poster_path'], + 'name' => $movie['title'], + 'original_title' => $movie['original_title'], + 'language' => $movie['original_language'], + 'is_series' => false, + 'release_date' => $movie['release_date'], + 'tagline' => $movie['tagline'], + 'description' => $movie['overview'], + 'runtime' => $movie['runtime'], + //////////////////'certification' => $movie[''], + 'budget' => $movie['budget'], + 'revenue' => $movie['revenue'], + 'popularity' => $movie['popularity'], + 'language' => $movie['original_language'], + 'adult' => $movie['adult'], + 'type' => 'movie', + 'tmdb_vote_average' => $movie['vote_average'] + ]); + + + $genres = $countrys = []; + + foreach($movie['production_countries'] as $country) + { + $productionCountry = ProductionCountry::updateOrCreate( + ['name' => $country['iso_3166_1']], + ['name' => $country['iso_3166_1'], 'display_name' => $country['name'] + ]); + $countrys[] = $productionCountry->id; + } + + foreach($movie['genres'] as $genre) + { + $genre = Genre::updateOrCreate( + ['name' => $genre['name']], + [ + 'name' => $genre['name'], + 'display_name' => $genre['name'] + ]); + $genres[] = $genre->id; + } + + $title->productionCountries()->attach($countrys); + $title->genres()->attach($genres); + + /*********************************************** */ + $responseMovieKeyword = Http::withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json', + ])->get("$this->baseUrl/movie/$movieId/keywords", [ + 'language' => 'fr-FR' + ]); + + $movieKeywords = $responseMovieKeyword->successful() ? $responseMovieKeyword->json() : []; + + $keywords = []; + foreach($movieKeywords['keywords'] as $valKey) + { + $keyword = Keyword::updateOrCreate( + ['name' => $valKey['name']], + [ + 'name' => $valKey['name'], + 'display_name' => $valKey['name'] + ]); + $keywords[] = $keyword->id; + } + $title->keywords()->attach($keywords); + + /************************************************/ + + $responseMovieCredits = Http::withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json', + ])->get("$this->baseUrl/movie/$movieId/credits", [ + 'language' => 'fr-FR' + ]); + + $movieCredits = $responseMovieCredits->successful() ? $responseMovieCredits->json() : []; + + + foreach($movieCredits['cast'] as $cast) + { + $person_id = $this->getPeopleByTMDBid($cast['id']); + $title->createCredit([ + 'person_id'=>$person_id, + 'department'=> "actors", + 'job'=>"actor", + 'order'=>$cast['order'], + 'character'=>$cast['character'], + ]); + + } + + foreach($movieCredits['crew'] as $cast) + { + $person_id = $this->getPeopleByTMDBid($cast['id']); + $title->createCredit([ + 'person_id'=>$person_id, + 'department'=> $cast['department'], + 'job'=>$cast['job'], + ]); + + } + + /********************* Videos *******************************/ + + $responseMovieVideos = Http::withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json', + ])->get("$this->baseUrl/movie/$movieId/videos", [ + 'language' => 'fr-FR' + ]); + + $MovieVideos = $responseMovieVideos->successful() ? $responseMovieVideos->json() : []; + foreach($MovieVideos['results'] as $keyV =>$video) + { + if( ($video['site'] == "YouTube") && ($video['type'] == "Trailer") ) + { + Video::create([ + 'name'=>$video['name'], + 'src'=>"https://youtube.com/embed/".$video['key'], + 'type'=>'embed', + 'quality'=> ($video['size']==1080)? "1080p" : null, + 'title_id'=>$title->id, + 'origin'=>"local", + 'downvotes'=>0, + 'upvotes'=>0, + 'approved'=>1, + 'order'=>$keyV, + 'user_id'=>auth()->id(), + 'language'=>$video['iso_639_1'], + 'category'=>'trailer', + ]); + } + } + + /***********************Images***************************/ + + $responseMovieImages = Http::withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json', + ])->get("$this->baseUrl/movie/$movieId/images", []); + + $movieImages = $responseMovieImages->successful() ? $responseMovieImages->json() : []; + for($i=0; $i<=5; $i++) + { + $nameFileImage = isset($movieImages['backdrops'][$i]['file_path'])? $movieImages['backdrops'][$i]['file_path'] : null; + if($nameFileImage) + { + $url = $this->saveImage("https://image.tmdb.org/t/p/original$nameFileImage"); + + Image::where('model_type', Title::MODEL_TYPE) + ->where('model_id', $title->id) + ->increment('order'); + + Image::create([ + 'url' => $url, + 'type' => 'backdrop', + 'source' => 'local', + 'model_type' => Title::MODEL_TYPE, + 'model_id' => $title->id, + 'order' => 0 + ]); + } + } + + } + + + + function getPeopleByTMDBid($tmdb_id) + { + $person = DB::table('people')->where('tmdb_id', $tmdb_id)->first(); + + if(!$person) + { + $responsePeople = Http::withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json', + ])->get("$this->baseUrl/person/$tmdb_id", [ + 'language' => 'fr-FR' + ]); + + $people = $responsePeople->successful() ? $responsePeople->json() : []; + $person = Person::create([ + 'tmdb_id' => $people['id'], + 'name' => $people['name'], + 'description'=>$people['biography'], + 'gender'=>$people['gender'], + 'birth_date'=>$people['birthday'], + 'birth_place'=>$people['place_of_birth'], + 'poster'=>"https://image.tmdb.org/t/p/w500".$people['profile_path'], + 'imdb_id'=>$people['imdb_id'], + 'known_for'=>$people['known_for_department'], + 'popularity'=>$people['popularity'], + 'death_date'=>$people['deathday'], + 'adult'=>$people['adult'], + ]); + } + return $person->id ; + } + + + public function saveImage(string $urlImage): string + { + $hash = Str::random(30); + $manager = new ImageManager(new Driver()); + + // Télécharger l'image depuis l'URL + $imageContent = file_get_contents($urlImage); + $tempFile = tempnam(sys_get_temp_dir(), 'img'); + file_put_contents($tempFile, $imageContent); + + try { + $img = $manager->read($tempFile); + + // Déterminer l'extension à partir de l'URL ou utiliser jpeg par défaut + $pathInfo = pathinfo(parse_url($urlImage, PHP_URL_PATH)); + $extension = $pathInfo['extension'] ?? 'jpeg'; + $extension = in_array(strtolower($extension), ['jpg', 'jpeg', 'png']) ? $extension : 'jpeg'; + + foreach ($this->sizes as $key => $size) { + if ($size) { + $img->scale($size); + } + + Storage::disk('public')->put( + "media-images/backdrops/$hash/$key.$extension", + $extension === 'png' ? $img->toPng() : $img->toJpeg(), + ); + } + + $endpoint = config('common.site.file_preview_endpoint'); + $uri = "media-images/backdrops/$hash/original.$extension"; + return $endpoint + ? "$endpoint/storage/$uri" + : Storage::disk('public')->url($uri); + } finally { + // Nettoyer le fichier temporaire + if (file_exists($tempFile)) { + unlink($tempFile); + } + } + } +} diff --git a/app/Http/Controllers/AppHomeController.php b/app/Http/Controllers/AppHomeController.php new file mode 100755 index 0000000..a240ced --- /dev/null +++ b/app/Http/Controllers/AppHomeController.php @@ -0,0 +1,38 @@ +renderChannel( + settings('homepage.value'), + ); + } else { + return $this->renderClientOrApi([ + 'pageName' => 'landing-page', + 'data' => [ + 'loader' => 'landingPage', + 'products' => Product::with(['permissions', 'prices']) + ->limit(15) + ->orderBy('position', 'asc') + ->get(), + 'trendingTitles' => Title::orderBy('popularity', 'desc') + ->compact() + ->limit(6) + ->get(), + ], + ]); + } + } +} diff --git a/app/Http/Controllers/ChannelItemController.php b/app/Http/Controllers/ChannelItemController.php new file mode 100755 index 0000000..07ee09c --- /dev/null +++ b/app/Http/Controllers/ChannelItemController.php @@ -0,0 +1,50 @@ +authorize('update', $channel); + + $data = $this->validate(request(), [ + 'itemId' => 'required|integer', + 'itemType' => 'required|string', + ]); + + $relationName = Str::plural($data['itemType']); + + $channel->$relationName()->sync( + [ + $data['itemId'] => [ + 'order' => $channel->$relationName()->count() + 1, + ], + ], + false, + ); + $channel->touch(); + + return $this->success(['channel' => $channel]); + } + + public function remove(Channel $channel) + { + $this->authorize('update', $channel); + + $data = $this->validate(request(), [ + 'itemId' => 'required|integer', + 'itemType' => 'required|string', + ]); + + $relationName = Str::plural($data['itemType']); + $channel->$relationName()->detach($data['itemId']); + $channel->touch(); + + return $this->success(['channel' => $channel]); + } +} diff --git a/app/Http/Controllers/EpisodeController.php b/app/Http/Controllers/EpisodeController.php new file mode 100755 index 0000000..6b9ed74 --- /dev/null +++ b/app/Http/Controllers/EpisodeController.php @@ -0,0 +1,104 @@ +loadData(request('loader')); + + $this->authorize('show', $data['title']); + + return $this->renderClientOrApi([ + 'data' => $data, + 'pageName' => 'episode-page', + ]); + } + + public function update(Title $title, int $seasonNumber, int $episodeNumber) + { + $this->authorize('update', $title); + + $episode = $title + ->episodes() + ->where('season_number', $seasonNumber) + ->where('episode_number', $episodeNumber) + ->firstOrFail(); + + $this->validate(request(), [ + 'episode_number' => [ + 'integer', + Rule::unique('episodes') + ->ignore($episode->episode_number, 'episode_number') + ->where(function (Builder $query) use ($episode) { + $query + ->where('season_number', $episode->season_number) + ->where('title_id', $episode->title_id); + }), + ], + ]); + + $episode->fill(request()->all())->save(); + + return $this->success(['episode' => $episode]); + } + + public function store(Title $title, int $seasonNumber) + { + $this->authorize('update', $title); + + $season = $title->findSeason($seasonNumber)->loadCount('episodes'); + + $this->validate(request(), [ + 'episode_number' => [ + 'integer', + Rule::unique('episodes')->where(function (Builder $query) use ( + $season, + ) { + $query + ->where('season_number', $season->number) + ->where('title_id', $season->title_id); + }), + ], + ]); + + $epNum = request('episode_number'); + if (!$epNum) { + $epNum = + $season + ->episodes() + ->orderBy('episode_number', 'desc') + ->value('episode_number') + 1; + } + + $episode = Episode::create( + array_merge(request()->all(), [ + 'season_number' => $season->number, + 'episode_number' => $epNum, + 'season_id' => $season->id, + 'title_id' => $season->title_id, + ]), + ); + + return $this->success(['episode' => $episode]); + } + + public function destroy(int $id) + { + $this->authorize('destroy', Title::class); + + $episode = Episode::findOrFail($id); + $episode->credits()->detach(); + $episode->delete(); + + return $this->success(); + } +} diff --git a/app/Http/Controllers/FallbackRouteController.php b/app/Http/Controllers/FallbackRouteController.php new file mode 100755 index 0000000..f9e7e29 --- /dev/null +++ b/app/Http/Controllers/FallbackRouteController.php @@ -0,0 +1,62 @@ + 2 || + count($parts) < 1 || + (count($parts) === 1 && in_array($parts[0], self::$defaultRoutes)) + ) { + return $this->renderClient(); + } + + // first try to match a channel, if none is found, fallback to rendering client side app + try { + if ($parts[0] === 'lists' && isset($parts[1])) { + request()->merge(['channelType' => 'list']); + $parts[0] = $parts[1]; + $parts[1] = null; + } + + if ($parts[0] === 'channel' && isset($parts[1])) { + $parts[0] = $parts[1]; + $parts[1] = null; + } + + $slugOrId = $parts[0]; + $restriction = $parts[1] ?? null; + return $this->renderChannel($slugOrId, $restriction); + } catch (ModelNotFoundException) { + return $this->renderClient(); + } + } + + public function renderChannel(string $slugOrId, ?string $restriction = null) + { + $channel = app(Channel::class)->resolveRouteBinding($slugOrId); + if ($restriction) { + request()->merge(['restriction' => $restriction]); + } + return app(ChannelController::class)->show($channel); + } + + public function renderClient() + { + // no need to prerender channels here, use base HomeController + request()->route()->action['uses'] = HomeController::class . '@show'; + return app(HomeController::class)->show(); + } +} diff --git a/app/Http/Controllers/ImageOrderController.php b/app/Http/Controllers/ImageOrderController.php new file mode 100755 index 0000000..a3fb8f6 --- /dev/null +++ b/app/Http/Controllers/ImageOrderController.php @@ -0,0 +1,43 @@ +title->findOrFail($titleId); + + $this->authorize('update', $title); + + $this->validate($this->request, [ + 'ids' => 'array|min:1', + 'ids.*' => 'integer' + ]); + + $queryPart = ''; + foreach($this->request->get('ids') as $order => $id) { + $queryPart .= " when id=$id then $order"; + } + + DB::table('images') + ->whereIn('id', $this->request->get('ids')) + ->update(['order' => DB::raw("(case $queryPart end)")]); + + return $this->success(); + } +} diff --git a/app/Http/Controllers/ImagesController.php b/app/Http/Controllers/ImagesController.php new file mode 100755 index 0000000..358ab21 --- /dev/null +++ b/app/Http/Controllers/ImagesController.php @@ -0,0 +1,67 @@ +findOrFail($titleId); + + $this->authorize('store', $model); + + $this->validate(request(), [ + 'file' => 'required|image|max:10240', + 'titleId' => 'required|integer', + ]); + + $url = app(StoreMediaImageOnDisk::class)->execute( + request()->file('file'), + ); + + // put new image at the start of the list when sorted by "order" + Image::where('model_type', Title::MODEL_TYPE) + ->where('model_id', $titleId) + ->increment('order'); + + $image = Image::create([ + 'url' => $url, + 'type' => 'backdrop', + 'source' => 'local', + 'model_type' => Title::MODEL_TYPE, + 'model_id' => $titleId, + 'order' => 0, + ]); + + return $this->success(['image' => $image]); + } + + public function destroy(int $id) + { + $img = Image::findOrFail($id); + $model = app(modelTypeToNamespace($img->model_type))->findOrFail( + $img->model_id, + ); + + $this->authorize('destroy', $model); + + if ($img->source === 'local') { + // storage/media-images/backdrops/kw4q4eg5g8q4eq6/original.jpg + $dir = str_replace('storage/', '', dirname($img->url)); + if (Storage::disk('public')->exists($dir)) { + Storage::disk('public')->deleteDirectory($dir); + } + } + + $img->delete(); + + return $this->success(); + } +} diff --git a/app/Http/Controllers/ImportMediaController.php b/app/Http/Controllers/ImportMediaController.php new file mode 100755 index 0000000..3d2f3de --- /dev/null +++ b/app/Http/Controllers/ImportMediaController.php @@ -0,0 +1,122 @@ +authorize('store', Title::class); + + $data = $this->validate(request(), [ + 'media_type' => 'required|string', + 'tmdb_id' => 'required|integer', + ]); + + if ($data['media_type'] === Person::MODEL_TYPE) { + $mediaItem = Person::withoutGlobalScope('adult') + ->firstOrCreate([ + 'tmdb_id' => $data['tmdb_id'], + ]) + ->maybeUpdateFromExternal([ + 'forceAutomation' => true, + 'ignoreLastUpdate' => true, + ]); + } else { + $mediaItem = Title::withoutGlobalScope('adult') + ->firstOrCreate([ + 'tmdb_id' => $data['tmdb_id'], + 'is_series' => $data['media_type'] === Title::SERIES_TYPE, + ]) + ->maybeUpdateFromExternal([ + 'forceAutomation' => true, + 'updateLast3Seasons' => true, + 'ignoreLastUpdate' => true, + ]); + } + + if (!$mediaItem) { + abort(404); + } + + return ['mediaItem' => $mediaItem]; + } + + public function importViaBrowse() + { + $this->authorize('store', Title::class); + + if (!config('services.tmdb.key')) { + abort( + 403, + 'Enter your Themoviedb API key in settings page before importing titles.', + ); + } + + @set_time_limit(0); + @ini_set('memory_limit', '200M'); + + $tmdbParams = [ + 'with_release_type' => '2|3', + ]; + + if (request('country')) { + $tmdbParams['with_origin_country'] = strtolower(request('country')); + } + if (request('language')) { + $tmdbParams['with_original_language'] = request('language'); + } + if (request('min_rating')) { + $tmdbParams['vote_average.gte'] = request('min_rating'); + } + if (request('max_rating')) { + $tmdbParams['vote_average.lte'] = request('max_rating'); + } + if (request('genres')) { + $tmdbParams['with_genres'] = request('genres'); + } + if (request('keywords')) { + $tmdbParams['with_keywords'] = request('keywords'); + } + if (request('start_date') && request('end_date')) { + $tmdbParams['release_date.gte'] = Carbon::parse( + request('start_date'), + )->format('Y-m-d'); + $tmdbParams['release_date.lte'] = Carbon::parse( + request('end_date'), + )->format('Y-m-d'); + } + + $response = app(TmdbApi::class)->browse( + request('current_page', 1), + request('type', 'movie'), + $tmdbParams, + ); + + $titles = $response['results'] + ->map(function ($result) { + return Title::withoutGlobalScope('adult') + ->firstOrCreate([ + 'tmdb_id' => $result['tmdb_id'], + 'is_series' => $result['is_series'], + ]) + ->maybeUpdateFromExternal([ + 'forceAutomation' => true, + ]); + }) + ->filter() + ->values(); + + return [ + 'titles' => $titles, + 'total_pages' => $response['total_pages'], + 'current_page' => request('current_page', 1), + ]; + } +} diff --git a/app/Http/Controllers/InsightsReportController.php b/app/Http/Controllers/InsightsReportController.php new file mode 100755 index 0000000..c1f5497 --- /dev/null +++ b/app/Http/Controllers/InsightsReportController.php @@ -0,0 +1,24 @@ +middleware('auth'); + } + + public function __invoke() + { + $report = app(BuildPlaysReport::class)->execute( + request()->all(), + ); + + return $this->success(['report' => $report]); + } +} diff --git a/app/Http/Controllers/ListsController.php b/app/Http/Controllers/ListsController.php new file mode 100755 index 0000000..c0cde0c --- /dev/null +++ b/app/Http/Controllers/ListsController.php @@ -0,0 +1,19 @@ +authorize('index', [Channel::class, 'list']); + + $pagination = (new ListsLoader())->allLists(request()->all()); + + return $this->success(['pagination' => $pagination]); + } +} diff --git a/app/Http/Controllers/NewsController.php b/app/Http/Controllers/NewsController.php new file mode 100755 index 0000000..7473312 --- /dev/null +++ b/app/Http/Controllers/NewsController.php @@ -0,0 +1,127 @@ +authorize('show', NewsArticle::class); + + $paginator = new Datasource(NewsArticle::query(), request()->all()); + + $pagination = $paginator->paginate(); + + if (request('stripHtml') || request('truncateBody')) { + $pagination + ->map(function (NewsArticle $article) { + if (request('stripHtml')) { + // remove html tags + $article->body = strip_tags($article->body); + + // remove last "...see full article" + $parts = explode('...', $article->body); + if ( + count($parts) > 1 && + Str::contains(last($parts), 'See full article') + ) { + array_pop($parts); + } + $article->body = implode('', $parts); + } + + if ($newLength = (int) request('truncateBody')) { + $article->body = Str::limit($article->body, $newLength); + } + + return $article; + }) + ->values(); + } + + return $this->success(['pagination' => $pagination]); + } + + public function show($slugOrId) + { + $article = NewsArticle::where('id', $slugOrId) + ->orWhere('slug', $slugOrId) + ->firstOrFail(); + + $this->authorize('show', $article); + + $data = [ + 'article' => $article, + 'related' => NewsArticle::compact() + ->where('id', '!=', $article->id) + ->orderBy('created_at', 'desc') + ->limit(10) + ->get(), + 'loader' => 'newsArticlePage', + ]; + + return $this->renderClientOrApi([ + 'pageName' => 'news-article-page', + 'data' => $data, + ]); + } + + public function update($id) + { + $article = NewsArticle::findOrFail($id); + + $this->authorize('update', $article); + + $data = $this->validate(request(), [ + 'title' => 'min:5|max:250', + 'body' => 'min:5', + 'image' => 'string', + 'slug' => 'string', + ]); + + $article->fill($data)->save(); + + return $this->success(['article' => $article]); + } + + public function store() + { + $this->authorize('store', NewsArticle::class); + + $data = $this->validate(request(), [ + 'title' => 'required|min:5|max:250', + 'body' => 'required|min:5', + 'image' => 'string', + 'slug' => 'required|string', + ]); + + $article = NewsArticle::create($data); + + return $this->success(['article' => $article]); + } + + public function destroy(string $ids) + { + $ids = explode(',', $ids); + $this->authorize('destroy', NewsArticle::class); + + NewsArticle::whereIn('id', $ids)->delete(); + + return $this->success(); + } + + public function importFromRemoteProvider() + { + $this->authorize('store', NewsArticle::class); + + app(ImportNewsFromRemoteProvider::class)->execute(); + + return $this->success(); + } +} diff --git a/app/Http/Controllers/PersonController.php b/app/Http/Controllers/PersonController.php new file mode 100755 index 0000000..be8565e --- /dev/null +++ b/app/Http/Controllers/PersonController.php @@ -0,0 +1,86 @@ +authorize('index', Person::class); + + $pagination = (new PaginatePeople())->execute(request()->all()); + + return $this->success(['pagination' => $pagination]); + } + + public function show($id, $name = null) + { + $this->authorize('show', Person::class); + + $loader = request('loader', 'personPage'); + + if (is_numeric($id) || ctype_digit($id)) { + $person = Person::findOrFail($id); + } else { + $person = Person::firstOrCreateFromEncodedTmdbId($id); + } + + if ($loader === 'personPage' && requestIsFromFrontend()) { + $person->maybeUpdateFromExternal(); + } + + $data = array_merge( + ['person' => $person, 'loader' => $loader], + app(GetPersonCredits::class)->execute($person), + ); + + (new IncrementModelViews())->execute($data['person']); + + return $this->renderClientOrApi([ + 'data' => $data, + 'pageName' => 'person-page', + ]); + } + + public function store() + { + $this->authorize('store', Person::class); + + $data = request()->all(); + $data['popularity'] = Arr::get($data, 'popularity') ?: 50; + $person = Person::create($data); + + return $this->success(['person' => $person]); + } + + public function update($id) + { + $this->authorize('update', Person::class); + + $person = Person::findOrFail($id); + + $data = request()->all(); + $data['popularity'] = Arr::get($data, 'popularity') ?: 50; + $person->fill($data)->save(); + + return $this->success(['person' => $person]); + } + + public function destroy(string $ids) + { + $ids = explode(',', $ids); + $this->authorize('destroy', Person::class); + + app(DeletePeople::class)->execute($ids); + + return $this->success(); + } +} diff --git a/app/Http/Controllers/PersonCreditsController.php b/app/Http/Controllers/PersonCreditsController.php new file mode 100755 index 0000000..e8d6e88 --- /dev/null +++ b/app/Http/Controllers/PersonCreditsController.php @@ -0,0 +1,30 @@ +authorize('show', Person::class); + + $credits = app(GetPersonCredits::class)->execute($person, [ + 'titleId' => $titleId, + ]); + + $title = Arr::first( + $credits['credits'][$department], + fn($title) => $title['id'] === (int) $titleId, + ); + + return $this->success(['credits' => $title['episodes']]); + } +} diff --git a/app/Http/Controllers/RelatedTitlesController.php b/app/Http/Controllers/RelatedTitlesController.php new file mode 100755 index 0000000..a294d75 --- /dev/null +++ b/app/Http/Controllers/RelatedTitlesController.php @@ -0,0 +1,24 @@ +authorize('index', Title::class); + + $title = Title::with('keywords', 'genres')->findOrFail($id); + + $related = app(GetRelatedTitles::class)->execute( + $title, + request()->all(), + ); + + return $this->success(['titles' => $related]); + } +} diff --git a/app/Http/Controllers/ReviewController.php b/app/Http/Controllers/ReviewController.php new file mode 100755 index 0000000..778fefc --- /dev/null +++ b/app/Http/Controllers/ReviewController.php @@ -0,0 +1,135 @@ +authorize('index', Review::class); + + $builder = Review::withCount('reports')->withTextOnly(); + + // will need to specify this outside of filters on edit title reviews page + if (request('reviewable_id') && request('reviewable_type')) { + $builder->where([ + 'reviewable_id' => request('reviewable_id'), + 'reviewable_type' => request('reviewable_type'), + ]); + } + + $datasource = new Datasource($builder, request()->all()); + $order = $datasource->getOrder(); + + if (Str::endsWith($order['col'], 'mostHelpful')) { + $datasource->order = false; + $builder->orderByMostHelpful(); + } + + $pagination = $datasource->paginate()->through(function ($review) { + if ($review->relationLoaded('reviewable') && $review->reviewable) { + $normalized = $review->reviewable->toNormalizedArray(); + $review->unsetRelation('reviewable'); + $review->setAttribute('reviewable', $normalized); + } + return $review; + }); + + return $this->success(['pagination' => $pagination]); + } + + public function update($id) + { + $review = Review::findOrFail($id); + + $this->authorize('update', $review); + + $data = request()->all(); + + if (isset($data['body'])) { + $data['has_text'] = true; + } + + $review->fill($data)->save(); + + app(UpdateReviewableAverageScore::class)->execute( + $review->reviewable_id, + $review->reviewable_type, + ); + + return $this->success(['review' => $review]); + } + + public function store() + { + $this->authorize('store', Review::class); + + $data = $this->validate(request(), [ + 'reviewable_id' => 'required|integer', + 'reviewable_type' => 'required|string', + 'title' => 'string|min:10|max:150', + 'body' => 'string|min:100|max:5000', + 'score' => 'required|integer|min:1|max:10', + ]); + + $reviewableId = $data['reviewable_id']; + $reviewableType = $data['reviewable_type']; + + $values = [ + 'score' => request('score'), + ]; + + // don't override review body or title when only score changes + if (request('body')) { + $values['body'] = request('body'); + $values['has_text'] = true; + } + if (request('title')) { + $values['title'] = request('title'); + } + + $review = Review::updateOrCreate( + [ + 'user_id' => Auth::id(), + 'reviewable_type' => $reviewableType, + 'reviewable_id' => $reviewableId, + ], + $values, + ); + + $review->load('user'); + app(UpdateReviewableAverageScore::class)->execute( + $reviewableId, + $reviewableType, + ); + + return $this->success(['review' => $review]); + } + + public function destroy(string $ids) + { + $reviewIds = explode(',', $ids); + + $reviews = Review::whereIn('id', $reviewIds)->get(); + + $this->authorize('destroy', [Review::class, $reviews]); + + $reviews->each(function (Review $review) { + app(UpdateReviewableAverageScore::class)->execute( + $review->reviewable_id, + $review->reviewable_type, + ); + }); + + Review::whereIn('id', $reviews->pluck('id'))->delete(); + + return $this->success(); + } +} diff --git a/app/Http/Controllers/ReviewFeedbackController.php b/app/Http/Controllers/ReviewFeedbackController.php new file mode 100755 index 0000000..1df5083 --- /dev/null +++ b/app/Http/Controllers/ReviewFeedbackController.php @@ -0,0 +1,44 @@ +authorize('show', $review); + + $data = $this->validate(request(), [ + 'is_helpful' => 'required|boolean', + ]); + $isHelpful = $data['is_helpful']; + + $review->feedback()->updateOrCreate( + [ + 'user_id' => auth()->id(), + ], + [ + 'is_helpful' => $isHelpful, + ], + ); + + $review->timestamps = false; + + if ($isHelpful) { + $review->increment('helpful_count'); + if ($review->not_helpful_count > 0) { + $review->decrement('not_helpful_count'); + } + } else { + if ($review->helpful_count > 0) { + $review->decrement('helpful_count'); + } + $review->increment('not_helpful_count'); + } + + return $this->success(['review' => $review->load('feedback')]); + } +} diff --git a/app/Http/Controllers/ReviewableController.php b/app/Http/Controllers/ReviewableController.php new file mode 100755 index 0000000..71d7cfd --- /dev/null +++ b/app/Http/Controllers/ReviewableController.php @@ -0,0 +1,29 @@ +loadData([ + 'reviewableType' => request('reviewable_type'), + 'reviewableId' => request('reviewable_id'), + 'page' => request('page'), + 'orderBy' => request('orderBy'), + 'orderDir' => request('orderDir'), + 'perPage' => request('perPage'), + ]); + + if (!$data) { + abort(404); + } + + $this->authorize('show', $data['reviewable']); + + return $this->success($data); + } +} diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php new file mode 100755 index 0000000..eea59a7 --- /dev/null +++ b/app/Http/Controllers/SearchController.php @@ -0,0 +1,73 @@ +searchUsing($dataProvider, $query) + ->map(function ($result) { + if (isset($result['description'])) { + $result['description'] = Str::limit( + $result['description'], + 140, + ); + } + return $result; + }) + ->values(); + + $data = [ + 'results' => $results, + 'query' => trim(strip_tags($query), '"\''), + 'loader' => 'searchPage', + ]; + + return $this->renderClientOrApi([ + 'pageName' => 'search-page', + 'data' => $data, + ]); + } + + private function searchUsing($provider, $query) + { + $params = request()->all(); + $params['limit'] = + request('loader', 'searchPage') === 'searchPage' ? 20 : 8; + + if ($provider === 'tmdb') { + return app(TmdbApi::class)->search($query, $params); + } + + $results = app(LocalSearch::class)->execute($query, $params); + + if ($provider === 'all') { + $tmdb = app(TmdbApi::class)->search($query, $params); + $results = $results + ->concat($tmdb) + ->unique( + fn($item) => ($item['tmdb_id'] ?: $item['name']) . + $item['model_type'], + ) + ->groupBy('model_type') + // make sure specified limit is enforced per group + // (title, person) instead of the whole collection + ->map( + fn(Collection $group) => $group->slice(0, $params['limit']), + ) + ->flatten(1) + ->sortByDesc('popularity'); + } + + return $results; + } +} diff --git a/app/Http/Controllers/SeasonController.php b/app/Http/Controllers/SeasonController.php new file mode 100755 index 0000000..f00393a --- /dev/null +++ b/app/Http/Controllers/SeasonController.php @@ -0,0 +1,59 @@ +loadData(request('loader')); + + $this->authorize('show', $data['title']); + + return $this->renderClientOrApi([ + 'pageName' => 'season-page', + 'data' => $data, + ]); + } + + public function store($titleId) + { + $this->authorize('update', Title::class); + + $title = Title::withCount('seasons')->findOrFail($titleId); + + $season = $title->seasons()->create([ + 'number' => $title->seasons_count + 1, + ]); + + return $this->success(['season' => $season]); + } + + public function destroy(int $seasonId) + { + $this->authorize('update', Title::class); + + app(DeleteSeasons::class)->execute([$seasonId]); + + return $this->success(); + } + + public function episodeNumbers() + { + $titleId = request()->route('titleId'); + $seasonNumber = request()->route('seasonNumber'); + + $episodeNumbers = (new LoadSeasonEpisodeNumbers())->execute( + $titleId, + $seasonNumber, + ); + + return $this->success(['episodeNumbers' => $episodeNumbers]); + } +} diff --git a/app/Http/Controllers/SeasonEpisodesController.php b/app/Http/Controllers/SeasonEpisodesController.php new file mode 100755 index 0000000..e05dad2 --- /dev/null +++ b/app/Http/Controllers/SeasonEpisodesController.php @@ -0,0 +1,23 @@ +authorize('show', $title); + + $pagination = app(PaginateSeasonEpisodes::class)->execute( + $title, + $seasonNumber, + request()->all(), + ); + + return $this->success(['pagination' => $pagination]); + } +} diff --git a/app/Http/Controllers/TitleAutocompleteController.php b/app/Http/Controllers/TitleAutocompleteController.php new file mode 100755 index 0000000..771f362 --- /dev/null +++ b/app/Http/Controllers/TitleAutocompleteController.php @@ -0,0 +1,61 @@ +authorize('index', Title::class); + + $search = request('searchQuery'); + $selectedTitleId = request('selectedTitleId'); + $seasonNumber = request('seasonNumber'); + $builder = app(Title::class); + + if ($search) { + $builder = $builder->search($search); + } + + $results = $builder + ->take(10) + ->get(['id', 'name', 'poster', 'release_date']); + + $results = $results->map(function (Title $title) { + $normalized = $title->toNormalizedArray(); + if ($title->relationLoaded('season')) { + $normalized['episodes_count'] = + $title->season->episodes_count ?? 0; + } + $normalized['seasons_count'] = $title->seasons_count; + return $normalized; + }); + + if ($selectedTitleId) { + $title = Title::find($selectedTitleId); + if ($title) { + $normalizedTitle = $title->toNormalizedArray(); + $normalizedTitle['seasons_count'] = Season::where( + 'title_id', + $title->id, + )->count(); + if ($seasonNumber) { + $normalizedTitle[ + 'episode_numbers' + ] = (new LoadSeasonEpisodeNumbers())->execute( + $title->id, + $seasonNumber, + ); + } + $results->prepend($normalizedTitle); + } + } + + return ['titles' => $results]; + } +} diff --git a/app/Http/Controllers/TitleController.php b/app/Http/Controllers/TitleController.php new file mode 100755 index 0000000..91c4bd6 --- /dev/null +++ b/app/Http/Controllers/TitleController.php @@ -0,0 +1,70 @@ +authorize('index', Title::class); + + $pagination = app(PaginateTitles::class)->execute(request()->all()); + + return $this->success(['pagination' => $pagination]); + } + + public function show() + { + $data = (new TitleLoader())->loadData(request('loader')); + + $this->authorize('show', $data['title']); + + (new IncrementModelViews())->execute($data['title']); + + return $this->renderClientOrApi([ + 'pageName' => 'title-page', + 'data' => $data, + ]); + } + + public function update(int $id) + { + $this->authorize('update', Title::class); + + $data = request()->all(); + $title = Title::findOrFail($id); + + $title = app(StoreTitleData::class)->execute($title, $data, [ + 'overrideWithEmptyValues' => true, + ]); + + return $this->success(['title' => $title]); + } + + public function store() + { + $this->authorize('store', Title::class); + + $title = Title::create(request()->all()); + + return $this->success(['title' => $title]); + } + + public function destroy(string $ids) + { + $titleIds = explode(',', $ids); + $this->authorize('destroy', Title::class); + + app(DeleteTitles::class)->execute($titleIds); + + return $this->success(); + } +} diff --git a/app/Http/Controllers/TitleCreditsController.php b/app/Http/Controllers/TitleCreditsController.php new file mode 100755 index 0000000..aa4265e --- /dev/null +++ b/app/Http/Controllers/TitleCreditsController.php @@ -0,0 +1,115 @@ +authorize('show', $title); + + $model = $this->resolveCreditableModel($title, request()->all()); + + $builder = $model->credits(); + + if (request('crewOnly')) { + $builder->wherePivot('department', '!=', 'actors'); + } + + if ($department = request('department')) { + $builder->wherePivot('department', $department); + } + + $datasource = new Datasource($builder, request()->all()); + $datasource->order = false; + + return $this->success(['pagination' => $datasource->paginate()]); + } + + public function update(Title $title, int $pivotId) + { + $this->authorize('update', $title); + + $data = $this->validate(request(), [ + 'character' => 'string|nullable', + 'department' => 'string|nullable', + 'job' => 'string|nullable', + ]); + + $model = $this->resolveCreditableModel($title, request()->all()); + $model->updateCredit($pivotId, $data); + + return $this->success(); + } + + public function store(Title $title) + { + $this->authorize('update', $title); + + $data = $this->validate(request(), [ + 'person_id' => 'required|integer|exists:people,id', + 'character' => 'required_if:department,cast|string', + 'department' => 'required|string', + 'job' => 'string|nullable', + ]); + + $model = $this->resolveCreditableModel($title, request()->all()); + $model->createCredit($data); + + return $this->success(); + } + + public function destroy(Title $title, int $pivotId) + { + $this->authorize('update', $title); + + $model = $this->resolveCreditableModel($title, request()->all()); + $model + ->credits() + ->wherePivot('id', $pivotId) + ->detach(); + + return $this->success(); + } + + public function changeOrder() + { + $this->authorize('update', Title::class); + + $data = $this->validate(request(), [ + 'ids' => 'array|min:1', + 'ids.*' => 'integer', + ]); + + $queryPart = ''; + foreach ($data['ids'] as $order => $id) { + $queryPart .= " when id=$id then $order"; + } + + DB::table('creditables') + ->whereIn('id', $data['ids']) + ->update(['order' => DB::raw("(case $queryPart end)")]); + + return $this->success(); + } + + public function resolveCreditableModel( + Title $title, + array $params, + ): Title|Season|Episode { + if (Arr::get($params, 'season') && Arr::get($params, 'episode')) { + return $title->findEpisode($params['season'], $params['episode']); + } elseif (Arr::get($params, 'season')) { + return $title->findSeason($params['season']); + } + return $title; + } +} diff --git a/app/Http/Controllers/TitleNewsController.php b/app/Http/Controllers/TitleNewsController.php new file mode 100755 index 0000000..8e8221d --- /dev/null +++ b/app/Http/Controllers/TitleNewsController.php @@ -0,0 +1,20 @@ +authorize('show', $title); + + $articles = $title->load([ + 'newsArticles' => fn($q) => $q->limit(4), + ])->newsArticles; + + return $this->success(['news_articles' => $articles]); + } +} diff --git a/app/Http/Controllers/TitleSeasonsController.php b/app/Http/Controllers/TitleSeasonsController.php new file mode 100755 index 0000000..c85406d --- /dev/null +++ b/app/Http/Controllers/TitleSeasonsController.php @@ -0,0 +1,22 @@ +authorize('show', $title); + + $pagination = app(PaginateTitleSeasons::class)->execute( + $title, + request()->all(), + ); + + return $this->success(['pagination' => $pagination]); + } +} diff --git a/app/Http/Controllers/TitleTagsController.php b/app/Http/Controllers/TitleTagsController.php new file mode 100755 index 0000000..3720115 --- /dev/null +++ b/app/Http/Controllers/TitleTagsController.php @@ -0,0 +1,46 @@ +authorize('update', $title); + + $data = $this->validate(request(), [ + 'tag_name' => 'required|string', + ]); + + $relation = $this->getRelationName($type); + + $tags = $title + ->$relation() + ->getModel() + ->insertOrRetrieve([$data['tag_name']]); + + $title->$relation()->syncWithoutDetaching($tags->pluck('id')); + + return $this->success(); + } + + public function destroy(Title $title, string $type, int $tagId) + { + $this->authorize('update', $title); + + $relation = $this->getRelationName($type); + + $title->$relation()->detach([$tagId]); + + return $this->success(); + } + + private function getRelationName($type) + { + return Str::plural(Str::camel($type)); + } +} diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php new file mode 100755 index 0000000..0055aa8 --- /dev/null +++ b/app/Http/Controllers/UserProfileController.php @@ -0,0 +1,147 @@ +authorize('show', $user); + + $user->load(['profile', 'links']); + $user->loadCount(['followers', 'followedUsers', 'lists']); + + $user->is_pro = $user->subscribed(); + + return $this->success(['user' => $user]); + } + + public function update() + { + $user = Auth::user(); + $this->authorize('update', $user); + + $data = $this->validate(request(), [ + 'user' => 'array', + 'profile' => 'array', + 'links' => 'array', + ]); + + User::unguard(true); + $oldAvatar = $user->avatar; + $user->fill($data['user'])->save(); + + if ( + isset($data['user']['avatar']) && + $oldAvatar !== $data['user']['avatar'] + ) { + event(new UserAvatarChanged($user)); + } + + $profile = $user + ->profile() + ->updateOrCreate(['user_id' => $user->id], $data['profile']); + + $user->links()->delete(); + $links = $user->links()->createMany($data['links']); + + $user->setRelation('profile', $profile); + $user->setRelation('links', $links); + + return $this->success(['user' => $user]); + } + + public function lists(User $user) + { + $this->authorize('show', $user); + + $pagination = (new ListsLoader())->forUser($user, request()->all()); + + return $this->success(['pagination' => $pagination]); + } + + public function ratings(User $user) + { + $this->authorize('show', $user); + + $datasource = new Datasource( + $user + ->reviews() + ->whereNull('body') + ->with([ + 'reviewable' => function (MorphTo $morphTo) { + $morphTo + ->morphWith([ + Episode::class => ['title'], + ]) + ->with('primaryVideo'); + }, + 'user', + ]), + request()->all(), + ); + + $pagination = $datasource->paginate(); + + return $this->success(['pagination' => $pagination]); + } + + public function reviews(User $user) + { + $this->authorize('show', $user); + + $datasource = new Datasource( + $user + ->reviews() + ->where('reviewable_type', Title::MODEL_TYPE) + ->whereNotNull('body') + ->with([ + 'reviewable' => function (MorphTo $morphTo) { + $morphTo->morphWith([ + Episode::class => ['title'], + ]); + }, + 'user', + ]), + request()->all(), + ); + + $pagination = $datasource->paginate(); + + return $this->success(['pagination' => $pagination]); + } + + public function comments(User $user) + { + $this->authorize('show', $user); + + $datasource = new Datasource( + $user + ->comments() + ->with([ + 'commentable' => function (MorphTo $morphTo) { + $morphTo->morphWith([ + Episode::class => ['title'], + ]); + }, + 'user', + ]) + ->where('deleted', false), + request()->all(), + ); + + $pagination = $datasource->paginate(); + + return $this->success(['pagination' => $pagination]); + } +} diff --git a/app/Http/Controllers/UserRatingsController.php b/app/Http/Controllers/UserRatingsController.php new file mode 100755 index 0000000..c9ff8e2 --- /dev/null +++ b/app/Http/Controllers/UserRatingsController.php @@ -0,0 +1,46 @@ +middleware('auth'); + } + + public function __invoke() + { + $ratings = Auth::user() + ->reviews() + ->select(['id', 'reviewable_id', 'reviewable_type', 'score']) + ->limit(1000) + ->get() + ->map( + fn($review) => [ + 'id' => $review->id, + 'score' => $review->score, + 'reviewable_id' => $review->reviewable_id, + 'type' => $review->reviewable_type, + ], + ) + ->groupBy('type') + ->map( + fn($group) => $group + ->mapWithKeys( + fn($item) => [ + $item['reviewable_id'] => [ + 'id' => $item['id'], + 'score' => $item['score'], + ], + ], + ) + ->all(), + ); + + return $this->success(['ratings' => $ratings]); + } +} diff --git a/app/Http/Controllers/UserWatchlistController.php b/app/Http/Controllers/UserWatchlistController.php new file mode 100755 index 0000000..8fa3d87 --- /dev/null +++ b/app/Http/Controllers/UserWatchlistController.php @@ -0,0 +1,47 @@ +middleware('auth'); + } + + public function __invoke() + { + $list = Auth::user() + ->watchlist() + ->firstOrFail(); + + $items = DB::table('channelables') + ->where('channel_id', $list->id) + ->pluck('channelable_type', 'channelable_id') + ->map( + fn($modelType, $itemId) => [ + 'id' => $itemId, + 'type' => $modelType, + ], + ) + ->groupBy('type') + ->map( + fn($group) => $group->mapWithKeys( + fn($item) => [ + $item['id'] => true, + ], + ), + ); + + return $this->success([ + 'watchlist' => [ + 'id' => $list->id, + 'items' => $items, + ], + ]); + } +} diff --git a/app/Http/Controllers/VideoApproveController.php b/app/Http/Controllers/VideoApproveController.php new file mode 100755 index 0000000..dd8cdab --- /dev/null +++ b/app/Http/Controllers/VideoApproveController.php @@ -0,0 +1,27 @@ +authorize('update', $video); + + $video->update(['approved' => true]); + + return $this->success(['video' => $video]); + } + + public function disapprove(Video $video) + { + $this->authorize('update', $video); + + $video->update(['approved' => false]); + + return $this->success(['video' => $video]); + } +} diff --git a/app/Http/Controllers/VideoOrderController.php b/app/Http/Controllers/VideoOrderController.php new file mode 100755 index 0000000..fd8912b --- /dev/null +++ b/app/Http/Controllers/VideoOrderController.php @@ -0,0 +1,33 @@ +authorize('update', $title); + + request()->validate([ + 'ids' => 'array|min:1', + 'ids.*' => 'integer', + ]); + + $queryPart = ''; + foreach (request('ids') as $order => $id) { + $queryPart .= " when id=$id then $order"; + } + + DB::table('videos') + ->whereIn('id', request('ids')) + ->update(['order' => DB::raw("(case $queryPart end)")]); + + return $this->success(); + } +} diff --git a/app/Http/Controllers/VideoReportController.php b/app/Http/Controllers/VideoReportController.php new file mode 100755 index 0000000..702a79f --- /dev/null +++ b/app/Http/Controllers/VideoReportController.php @@ -0,0 +1,35 @@ +request->ip(); + + // if we can't match current user, bail + if ( ! $userId && ! $userIp) return null; + + $alreadyReported = $video->reports() + ->where(function(Builder $query) use($userId, $userIp) { + $query->where('user_id', $userId)->orWhere('user_ip', $userIp); + })->first(); + + if ($alreadyReported) { + return $this->error(__('You have already reported this video.')); + } else { + $report = $video->reports()->create([ + 'user_id' => $userId, + 'user_ip' => $userIp + ]); + return $this->success(['report' => $report]); + } + } +} diff --git a/app/Http/Controllers/VideosController.php b/app/Http/Controllers/VideosController.php new file mode 100755 index 0000000..5a4ede8 --- /dev/null +++ b/app/Http/Controllers/VideosController.php @@ -0,0 +1,172 @@ +authorize('index', Video::class); + + $builder = Video::with([ + 'captions', + 'title' => fn(BelongsTo $query) => $query->with('seasons'), + ])->withCount(['reports', 'plays']); + + $filters = new DatasourceFilters(request('filters')); + + if ( + $titleFilter = + $filters->getAndRemove('title_id') ?? request('title_id') + ) { + $titleId = $titleFilter['value'] ?? request('title_id'); + $season = $titleFilter['season'] ?? request('season'); + $episode = $titleFilter['episode'] ?? request('episode'); + $builder->where('title_id', $titleId); + $builder->when($season, fn($q) => $q->where('season_num', $season)); + $builder->when( + $episode, + fn($q) => $q->where('episode_num', $episode), + ); + } + + $datasource = new Datasource($builder, request()->all(), $filters); + $order = $datasource->getOrder(); + + if (Str::endsWith($order['col'], 'upvotes')) { + $datasource->order = false; + $builder->orderByMostUpvotes(); + } + + // order by percentage of likes, taking into account total amount of likes and dislikes + if (Str::endsWith($order['col'], 'score')) { + $datasource->order = false; + $builder->orderByWeightedScore(); + } + + // add a secondary order by episode number if ordering by season number + if (Str::endsWith($order['col'], 'season_num')) { + $datasource->order = false; + $builder + ->orderBy('season_num', 'desc') + ->orderBy('episode_num', 'desc'); + } + + return $this->success(['pagination' => $datasource->paginate()]); + } + + public function show(Video $video) + { + $this->authorize('show', Video::class); + + $video->load(['captions', 'title']); + + return $this->success(['video' => $video]); + } + + public function store() + { + $this->authorize('store', Video::class); + + $this->validate( + request(), + [ + 'title_id' => 'required|integer', + 'name' => ['required', 'string', 'min:3', 'max:250'], + 'src' => 'required|max:1000', + 'type' => 'required|string|min:3|max:250', + 'category' => 'required|string|min:3|max:20', + 'quality' => 'nullable|string|min:2|max:250', + 'language' => 'required|nullable|string|max:10', + 'season_num' => 'nullable|integer', + 'episode_num' => 'requiredWith:season|integer|nullable', + 'captions' => 'nullable|array', + 'captions.*.name' => 'required|string|max:100', + 'captions.*.url' => 'required|string|max:250', + 'captions.*.language' => 'required|string|max:100', + ], + [ + 'title_id.*' => __( + 'Select a title this video should be attached to.', + ), + ], + ); + + $video = app(CrupdateVideo::class)->execute(request()->all()); + + return $this->success(['video' => $video]); + } + + public function update($id) + { + $this->authorize('update', Video::class); + + $this->validate( + request(), + [ + 'name' => 'string|min:3|max:250', + 'src' => 'required|max:1000', + 'type' => 'string|min:3|max:1000', + 'quality' => 'nullable|string|min:2|max:250', + 'language' => 'required|nullable|string|max:10', + 'title_id' => 'integer', + 'season_num' => 'nullable|integer', + 'episode_num' => 'requiredWith:season|integer|nullable', + 'captions' => 'nullable|array', + 'captions.*.name' => 'required|string|max:100', + 'captions.*.url' => 'required|string|max:250', + 'captions.*.language' => 'required|string|max:100', + ], + [ + 'title_id.*' => __( + 'Select a title this video should be attached to.', + ), + ], + ); + + $video = app(CrupdateVideo::class)->execute(request()->all(), $id); + + return $this->success(['video' => $video]); + } + + public function destroy($ids) + { + $ids = explode(',', $ids); + $this->authorize('destroy', [Video::class, $ids]); + + foreach ($ids as $id) { + $video = Video::find($id); + if (is_null($video)) { + continue; + } + + $video->delete(); + } + + return $this->success(); + } + + public function logPlay(Video $video) + { + $this->authorize('show', Video::class); + + if (request()->getContentType() === 'application/json') { + $data = request()->all(); + } else { + $data = json_decode(request()->getContent(), true); + } + + app(LogVideoPlay::class)->execute($video, $data); + + return $this->success(); + } +} diff --git a/app/Http/Controllers/WatchController.php b/app/Http/Controllers/WatchController.php new file mode 100755 index 0000000..75b7885 --- /dev/null +++ b/app/Http/Controllers/WatchController.php @@ -0,0 +1,197 @@ +authorize('show', $video); + + $video = $this->loadVideoRelations($video); + + (new LogVideoPlay())->execute($video); + + $title = $this->loadTitle($video); + + $episode = $video->episode_id + ? Episode::findOrFail($video->episode_id) + : null; + + $data = [ + 'loader' => 'watchPage', + 'title' => $title, + 'episode' => $episode, + 'video' => $video, + 'related_videos' => $this->videosByTags($video->title), + 'alternative_videos' => settings('streaming.show_video_selector') + ? $this->loadAlternativeVideos($video) + : [], + ]; + + return $this->renderClientOrApi([ + 'data' => $data, + 'pageName' => 'watch-page', + ]); + } + + protected function loadTitle(Video $video) + { + $title = Title::select([ + 'id', + 'name', + 'description', + 'backdrop', + 'poster', + 'is_series', + ]) + ->withCount('seasons') + ->findOrFail($video->title_id); + + $title->description = Str::limit($title->description, 310); + + return $title; + } + + protected function loadVideoRelations(Video $video) + { + $video->load([ + 'captions', + 'reports' => fn($q) => $q + ->where('user_id', auth()->id()) + ->orWhere('user_ip', getIp()), + 'latestPlay' => function (HasOne $builder) { + $builder + ->forCurrentUser() + ->whereNotNull('time_watched') + ->select(['id', 'video_id', 'time_watched']); + }, + ]); + if ($video->reports->first()) { + $video->current_user_reported = true; + } + return $video; + } + + private function videosByEpisode(Title $title, int $seasonNum) + { + [$col, $direction] = explode(':', settings('streaming.default_sort')); + + $currSeasonNum = $seasonNum; + $prevSeasonNum = $seasonNum - 1; + $nextSeasonNum = $seasonNum + 1; + + $videos = $title + ->videos() + ->with(['captions', 'episode', 'title' => fn($q) => $q->compact()]) + ->where('approved', true) + ->where('category', 'full') + ->whereNotNull('episode_id') + ->whereIn('season_num', [ + $currSeasonNum, + $nextSeasonNum, + $prevSeasonNum, + ]) + ->orderBy($col, $direction) + ->groupBy(['season_num', 'episode_num']) + ->get(); + + if ($videos->isEmpty()) { + return []; + } + + $grouped = $videos + ->groupBy('season_num') + ->map(function (Collection $videos, $seasonNum) use ( + $prevSeasonNum, + $nextSeasonNum, + ) { + if ($seasonNum === $prevSeasonNum) { + return $videos->sortByDesc('episode_num')->first(); + } elseif ($seasonNum === $nextSeasonNum) { + return $videos->sortByDesc('episode_num')->last(); + } else { + // current season episodes + return $videos->sortBy('season')->sortBy('episode_num'); + } + }); + + $videos = $grouped->get($currSeasonNum)->values(); + + // make sure prev season last episode appears first + if ($prevSeason = $grouped->get($prevSeasonNum)) { + $videos->prepend($prevSeason); + } + + // make sure next season first episode appears last + if ($nextSeason = $grouped->get($nextSeasonNum)) { + $videos->push($nextSeason); + } + + return $videos->values(); + } + + private function videosByTags(Title $title) + { + $title->load(['keywords', 'genres']); + + $related = app(GetRelatedTitles::class)->execute($title, [ + 'limit' => 6, + 'compact' => true, + ]); + + $videos = []; + + if ($related->isNotEmpty()) { + $related->load([ + 'videos' => fn(HasMany $builder) => $builder + ->where('approved', true) + ->fromConfiguredCategory(), + ]); + $videos = $related + ->map(function (Title $title) { + if ($video = $title->videos->first()) { + $title->setRelation('videos', []); + $video->title = $title; + return $video; + } + }) + ->filter() + ->values(); + } + + return $videos; + } + + protected function loadAlternativeVideos(Video $video) + { + $builder = Video::where('title_id', $video->title_id); + if ($video->season_num) { + $builder->where('season_num', $video->season_num); + } + if ($video->episode_num) { + $builder->where('episode_num', $video->episode_num); + } + + return $builder + ->where('approved', true) + ->limit(10) + ->when( + settings('streaming.prefer_full'), + fn($query) => $query->where('category', 'full'), + ) + ->applySelectedSort() + ->get(); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php new file mode 100755 index 0000000..d355074 --- /dev/null +++ b/app/Http/Kernel.php @@ -0,0 +1,75 @@ + [ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + AuthenticateSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + ], + + 'api' => [ + EnsureFrontendRequestsAreStateful::class, + 'throttle:1500,1', + SubstituteBindings::class, + ], + ]; + + protected $routeMiddleware = [ + 'auth' => Authenticate::class, + 'auth.basic' => AuthenticateWithBasicAuth::class, + 'bindings' => SubstituteBindings::class, + 'can' => Authorize::class, + 'guest' => RedirectIfAuthenticated::class, + 'password.confirm' => RequirePassword::class, + 'signed' => ValidateSignature::class, + 'throttle' => ThrottleRequests::class, + 'verified' => EnsureEmailIsVerified::class, + 'prerenderIfCrawler' => PrerenderIfCrawler::class, + 'optionalAuth' => OptionalAuthenticate::class, + ]; +} diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php new file mode 100755 index 0000000..3f13db6 --- /dev/null +++ b/app/Http/Middleware/EncryptCookies.php @@ -0,0 +1,16 @@ +check()) { + return response()->json(['status' => 'error', 'message' => 'already logged in'], 403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/TrimStrings.php b/app/Http/Middleware/TrimStrings.php new file mode 100755 index 0000000..943e9a4 --- /dev/null +++ b/app/Http/Middleware/TrimStrings.php @@ -0,0 +1,18 @@ +getMethod() === 'POST' ? 'required' : ''; + $ignore = $this->getMethod() === 'PUT' ? $this->route('caption')->id : ''; + $userId = $this->route('caption') ? $this->route('caption')->user_id : Auth::id(); + + return [ + 'name' => [ + $required, 'string', 'min:2', + Rule::unique('video_captions')->where('video_id', $userId)->ignore($ignore) + ], + 'language' => "$required|string|max:5", + 'caption_file' => "$required|file|mimes:txt", + 'video_id' => "$required|integer", + ]; + } +} diff --git a/app/Http/Resources/ChannelResource.php b/app/Http/Resources/ChannelResource.php new file mode 100755 index 0000000..04398fa --- /dev/null +++ b/app/Http/Resources/ChannelResource.php @@ -0,0 +1,112 @@ +config, [ + 'seoTitle', + 'seoDescription', + 'adminDescription', + 'presetId', + ]); + + return [ + 'id' => $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'public' => $this->public, + 'description' => $this->description, + 'config' => $config, + 'model_type' => $this->model_type, + 'restriction' => $this->restriction?->toArray(), + 'type' => $this->type, + 'content' => [ + 'current_page' => $this->content->currentPage(), + 'from' => $this->content->firstItem(), + //'last_page' => $this->content->lastPage(), + 'next_page' => $this->content->hasMorePages() + ? $this->content->currentPage() + 1 + : null, + 'per_page' => $this->content->perPage(), + 'prev_page' => + $this->content->currentPage() > 1 + ? $this->content->currentPage() - 1 + : null, + 'to' => $this->content->lastItem(), + 'total' => + $this->content instanceof LengthAwarePaginator + ? $this->content->total() + : null, + 'data' => $this->content + ->getCollection() + ->map(function ($item) use ($request) { + return match ($item->model_type) { + Channel::MODEL_TYPE => (new ChannelResource( + $item, + ))->toArray($request), + Title::MODEL_TYPE => [ + 'id' => $item->id, + 'name' => $item->name, + 'release_date' => $item->release_date, + 'poster' => $item->poster, + 'backdrop' => $item->backdrop, + 'is_series' => $item->is_series, + 'rating' => $item->rating, + 'runtime' => $item->runtime, + 'model_type' => $item::MODEL_TYPE, + 'status' => $item->status, + 'certification' => $item->certification, + 'description' => Str::limit( + $item->description, + 200, + ), + 'primary_video' => $item->relationLoaded( + 'primaryVideo', + ) + ? $item->primaryVideo?->toArray() + : null, + ], + Person::MODEL_TYPE => [ + 'id' => $item->id, + 'name' => $item->name, + 'poster' => $item->poster, + 'primary_credit' => $item->primary_credit, + 'known_for' => $item->known_for, + 'birth_date' => $item->birth_date, + 'death_date' => $item->death_date, + 'model_type' => $item::MODEL_TYPE, + 'description' => Str::limit( + $item->description, + 200, + ), + ], + NewsArticle::MODEL_TYPE => [ + 'id' => $item->id, + 'title' => $item->title, + 'slug' => $item->slug, + 'image' => $item->image, + 'source' => $item->source, + 'source_url' => $item->source_url, + 'byline' => $item->byline, + 'model_type' => $item::MODEL_TYPE, + 'created_at' => $item->created_at, + 'body' => Str::limit($item->body, 340), + ], + }; + }), + ], + ]; + } +} diff --git a/app/Jobs/IncrementModelViews.php b/app/Jobs/IncrementModelViews.php new file mode 100755 index 0000000..2a118b1 --- /dev/null +++ b/app/Jobs/IncrementModelViews.php @@ -0,0 +1,37 @@ +shouldIncrement($model)) { + return; + } + + session()->put( + "{$model->model_type}-views.{$model->model_id}", + Carbon::now()->timestamp, + ); + + $model->increment('views'); + } + + private function shouldIncrement(Person|Title $model): bool + { + $views = session("{$model->model_type}-views"); + + // user has not viewed this model yet + if (!$views || !isset($views[$model->id])) { + return true; + } + + // see if user last viewed this model over 10 hours ago + $time = Carbon::createFromTimestamp($views[$model->id]); + + return Carbon::now()->diffInHours($time) > 10; + } +} diff --git a/app/Listeners/CreateWatchlist.php b/app/Listeners/CreateWatchlist.php new file mode 100755 index 0000000..bdd69ca --- /dev/null +++ b/app/Listeners/CreateWatchlist.php @@ -0,0 +1,29 @@ +user->watchlist) return; + + Channel::create([ + 'name' => 'watchlist', + 'user_id' => $event->user->id, + 'internal' => true, + 'public' => false, + 'type' => 'list', + 'config' => [ + 'contentType' => 'manual', + 'contentOrder' => 'channelables.order:asc', + 'contentModel' => 'title', + 'layout' => 'grid', + 'preventDeletion' => true, + ] + ]); + } +} diff --git a/app/Listeners/DeleteUserChannels.php b/app/Listeners/DeleteUserChannels.php new file mode 100755 index 0000000..4a5d5aa --- /dev/null +++ b/app/Listeners/DeleteUserChannels.php @@ -0,0 +1,20 @@ +users->pluck('id'), + )->get(); + + app(DeleteChannels::class)->execute($channels); + } +} diff --git a/app/Loaders/EpisodeLoader.php b/app/Loaders/EpisodeLoader.php new file mode 100755 index 0000000..927a133 --- /dev/null +++ b/app/Loaders/EpisodeLoader.php @@ -0,0 +1,59 @@ +route('pageName'); + $title = Title::with('genres')->findOrFail(request()->route('titleId')); + $season = $title->findSeason(request()->route('seasonNumber')); + $episode = $season->findEpisode(request()->route('episodeNumber')); + + if (!$loader) { + $loader = + $pageName === 'full-credits' + ? 'episodeCreditsPage' + : 'episodePage'; + } + + if ( + ($loader === 'episodePage' || $loader === 'episodeCreditsPage') && + requestIsFromFrontend() + ) { + $season = $season->maybeUpdateFromExternal($title); + if (!$season) { + abort(404); + } + } + + $response = [ + 'title' => $title, + 'episode' => $episode, + 'loader' => $loader, + ]; + + if ($loader === 'episodePage') { + $episode->load(['videos', 'primaryVideo']); + $response['credits'] = app(TitleCredits::class)->loadCompact( + $title, + $season, + $episode, + ); + } + + if ($loader === 'episodeCreditsPage') { + $response['credits'] = app(TitleCredits::class)->loadFull( + $title, + $season, + $episode, + ); + } + + return $response; + } +} diff --git a/app/Loaders/ReviewsLoader.php b/app/Loaders/ReviewsLoader.php new file mode 100755 index 0000000..c7d7db3 --- /dev/null +++ b/app/Loaders/ReviewsLoader.php @@ -0,0 +1,78 @@ +find($modelId); + if (!$reviewable) { + return null; + } + + $sharedReviewId = request('sharedReviewId'); + $page = (int) ($params['page'] ?? 1); + $orderBy = $params['orderBy'] ?? 'created_at'; + $orderDir = $params['orderDir'] ?? 'desc'; + + $response = [ + 'reviewable' => $reviewable, + ]; + + $response['pagination'] = $reviewable + ->reviews() + ->with([ + 'user', + 'feedback' => fn($q) => $q->where('user_id', auth()->id()), + 'reports' => fn($q) => $q + ->where('user_id', auth()->id()) + ->orWhere('user_ip', getIp()), + ]) + ->withTextOnly() + ->when( + $orderBy === 'mostHelpful', + fn($q) => $q->orderByMostHelpful(), + fn($q) => $q->orderBy($orderBy, $orderDir), + ) + ->paginate($params['perPage'] ?? 10) + ->through(function ($review) { + if ($feedback = $review->feedback->first()) { + $review->current_user_feedback = $feedback->is_helpful; + } + if ($review->reports->first()) { + $review->current_user_reported = true; + } + $review->unsetRelation('feedback'); + $review->unsetRelation('reports'); + return $review; + }); + + $response['current_user_review'] = + $page === 1 + ? $reviewable + ->reviews() + ->where('user_id', auth()->id()) + ->first() + : null; + + $response['shared_review'] = + $sharedReviewId && $page === 1 + ? $reviewable + ->reviews() + ->find($sharedReviewId) + ?->load('user') + : null; + + return $response; + } +} diff --git a/app/Loaders/SeasonLoader.php b/app/Loaders/SeasonLoader.php new file mode 100755 index 0000000..c44a487 --- /dev/null +++ b/app/Loaders/SeasonLoader.php @@ -0,0 +1,67 @@ +route('titleId'); + $seasonNumber = request()->route('seasonNumber'); + + $title = Title::withCount('seasons')->findOrFail($titleId); + $season = $title->findSeason($seasonNumber); + $season->loadCount('episodes'); + + if ($loader === 'seasonPage' && requestIsFromFrontend()) { + $season = $season->maybeUpdateFromExternal($title); + if (!$season) { + abort(404); + } + } + + $response = [ + 'title' => $title, + 'season' => $season, + 'loader' => $loader, + ]; + + if ($loader === 'seasonPage' || $loader === 'editSeasonPage') { + $response['episodes'] = app(PaginateSeasonEpisodes::class)->execute( + $title, + $seasonNumber, + request()->all(), + ); + } + + if ($loader === 'seasonPage') { + $primaryVideo = Video::where('title_id', $season->title_id) + ->select([ + 'id', + 'title_id', + 'name', + 'category', + 'episode_id', + 'season_num', + 'episode_num', + ]) + ->where('season_num', $season->number) + ->when(settings('streaming.prefer_full'), function ($query) { + $query->where('category', 'full'); + }) + ->applySelectedSort() + ->first(); + $season->primary_video = $primaryVideo; + } + + return $response; + } +} diff --git a/app/Loaders/TitleLoader.php b/app/Loaders/TitleLoader.php new file mode 100755 index 0000000..0f98ab8 --- /dev/null +++ b/app/Loaders/TitleLoader.php @@ -0,0 +1,148 @@ +route('pageName'); + $titleId = request()->route('id'); + + if (!$loader) { + $loader = + $pageName === 'full-credits' ? 'titleCreditsPage' : 'titlePage'; + } + + if (is_numeric($titleId) || ctype_digit($titleId)) { + $title = Title::findOrFail($titleId); + } else { + $title = Title::firstOrCreateFromEncodedTmdbId($titleId); + } + + if ( + ($loader === 'titlePage' || $loader === 'titleCreditsPage') && + requestIsFromFrontend() + ) { + $title = $title->maybeUpdateFromExternal(); + if (!$title) { + abort(404); + } + } + + $response = [ + 'title' => $title->loadCount('seasons'), + 'loader' => $loader, + ]; + + if ($title->language) { + $lang = Arr::first( + app(ValueLists::class)->languages(), + fn($lang) => $lang['code'] === $title->language, + ); + $response['language'] = $lang['name'] ?? null; + } + + if ($loader === 'titlePage') { + return $this->loadTitlePage($response); + } + if ($loader === 'titleCreditsPage') { + return $this->loadTitleCreditsPage($response); + } + if ($loader === 'editTitlePage') { + $title->load([ + 'images', + 'genres', + 'keywords', + 'productionCountries', + ]); + return $response; + } + + return $response; + } + + private function loadTitlePage(array $response): array + { + $enabledSections = settings('title_page.sections'); + $title = $response['title']; + $title->load([ + 'images', + 'genres', + 'productionCountries', + 'keywords', + 'primaryVideo', + ]); + $response = $this->loadVideos($response); + + if (in_array('episodes', $enabledSections)) { + $response['episodes'] = app(PaginateSeasonEpisodes::class)->execute( + $title, + 1, + request()->all(), + ); + } + + if (in_array('seasons', $enabledSections)) { + $response = $this->loadSeasons($response); + } + + return $this->loadCompactCredits($response); + } + + private function loadTitleCreditsPage(array $response): array + { + $title = $response['title']; + $title->load(['genres', 'primaryVideo']); + $response = $this->loadSeasons($response); + $response = $this->loadVideos($response); + return $this->loadCredits($response); + } + + private function loadSeasons(array $response): array + { + if ($response['title']->is_series) { + $response['seasons'] = app(PaginateTitleSeasons::class)->execute( + $response['title'], + ); + } + return $response; + } + + private function loadVideos(array $response): array + { + $response['title']->load([ + 'videos' => function (HasMany $query) { + $query + ->where('approved', true) + ->whereNull('episode_num') + ->fromConfiguredCategory(); + }, + ]); + return $response; + } + + private function loadCredits(array $response): array + { + $response['credits'] = app(TitleCredits::class)->loadFull( + $response['title'], + ); + return $response; + } + + private function loadCompactCredits(array $response): array + { + $response['credits'] = app(TitleCredits::class)->loadCompact( + $response['title'], + ); + return $response; + } +} diff --git a/app/Models/Channel.php b/app/Models/Channel.php new file mode 100755 index 0000000..2da824e --- /dev/null +++ b/app/Models/Channel.php @@ -0,0 +1,193 @@ + 'integer', + 'public' => 'boolean', + 'internal' => 'boolean', + 'user_id' => 'integer', + ]; + + protected $hidden = ['pivot', 'internal']; + + public function allTitles(array $params, $builder = null): AbstractPaginator + { + if (!$builder && $this->restriction) { + $builder = $this->restriction->titles(); + } + return (new PaginateTitles())->execute($params, $builder); + } + + public function allMovies(array $params, $builder = null) + { + if (!$builder && $this->restriction) { + $builder = $this->restriction->titles(); + } + $params['type'] = Title::MOVIE_TYPE; + return (new PaginateTitles())->execute($params, $builder); + } + + public function allSeries(array $params, $builder = null) + { + if (!$builder && $this->restriction) { + $builder = $this->restriction->titles(); + } + $params['type'] = Title::SERIES_TYPE; + return (new PaginateTitles())->execute($params, $builder); + } + + public function titles(): MorphToMany + { + return $this->channelableRelation(Title::class); + } + + public function movies(): MorphToMany + { + return $this->channelableRelation(Title::class)->where( + 'titles.is_series', + false, + ); + } + + public function series(): MorphToMany + { + return $this->channelableRelation(Title::class)->where( + 'titles.is_series', + true, + ); + } + + public function people(): MorphToMany + { + return $this->channelableRelation(Person::class); + } + + public function allPeople( + array $params, + mixed $builder = null, + Channel $parentChannel = null, + ): AbstractPaginator { + $params['compact'] = !is_null($parentChannel); + return (new PaginatePeople())->execute($params, $builder); + } + + public function newsArticles(): MorphToMany + { + return $this->channelableRelation(NewsArticle::class)->select([ + 'news_articles.id', + 'news_articles.title', + 'news_articles.created_at', + ]); + } + + public function allNewsArticles( + array $params, + mixed $builder = null, + ): AbstractPaginator { + $datasource = new Datasource($builder ?? NewsArticle::query(), $params); + + $paginator = $datasource->paginate(); + + $paginator->transform(function (NewsArticle $article) { + $article->body = Str::limit(strip_tags($article->body), 400); + return $article; + }); + + return $paginator; + } + + protected function loadContentFromExternal( + string $autoUpdateMethod, + ): Collection|array|null { + $provider = Arr::get($this->config, 'autoUpdateProvider', 'local'); + $modelType = Arr::get($this->config, 'contentModel', 'movie'); + + $filters = []; + if (isset($this->config['restriction'])) { + $filters[$this->config['restriction']] = + $this->config['restrictionModelId']; + } + + if ($provider === 'tmdb') { + $keywords = collect(); + if (isset($filters['keyword'])) { + $keywords->push($filters['keyword']); + } + if (isset($this->config['tmdb_keywords'])) { + $keywords = $keywords + ->merge($this->config['tmdb_keywords']) + ->unique(); + } + if (isset($this->config['tmdb_language'])) { + $filters['language'] = $this->config['tmdb_language']; + } + $filters['keyword'] = $keywords->implode(','); + + return app(FetchContentFromTmdb::class)->execute( + $autoUpdateMethod, + $modelType, + $filters, + ); + } else { + return app(FetchContentFromLocalDatabase::class)->execute( + $autoUpdateMethod, + $modelType, + $filters, + ); + } + } + + protected function channelableRelation(string $type): MorphToMany + { + return $this->morphedByMany( + $type, + 'channelable', + null, + 'channel_id', + )->withPivot(['id', 'channelable_id', 'order']); + } + + public function resolveRouteBinding($value, $field = null) + { + $type = request('channelType'); + if ($value === 'watchlist') { + if (!Auth::check()) { + abort(401); + } + $channel = Auth::user() + ->watchlist() + ->firstOrFail(); + } elseif (ctype_digit($value)) { + $channel = app(Channel::class) + ->when($type, fn($q) => $q->where('type', $type)) + ->findOrFail($value); + } else { + $channel = app(Channel::class) + ->where('slug', $value) + ->when($type, fn($q) => $q->where('type', $type)) + ->firstOrFail(); + } + + if ($channel->type === 'list') { + $channel->load('user'); + } + + return $channel; + } +} diff --git a/app/Models/Episode.php b/app/Models/Episode.php new file mode 100755 index 0000000..545472d --- /dev/null +++ b/app/Models/Episode.php @@ -0,0 +1,166 @@ + 'integer', + 'episode_number' => 'integer', + 'season_number' => 'integer', + 'year' => 'integer', + 'title_id' => 'integer', + 'season_id' => 'integer', + 'allow_update' => 'boolean', + 'tmdb_vote_count' => 'integer', + 'tmdb_vote_average' => 'float', + 'popularity' => 'integer', + 'runtime' => 'integer', + 'rating' => 'float', + 'vote_count' => 'integer', + 'release_date' => 'date', + ]; + + public $hidden = [ + 'imdb_rating', + 'imdb_votes_num', + 'tmdb_vote_average', + 'local_vote_average', + 'local_vote_count', + 'tmdb_vote_count', + 'mc_user_score', + 'mc_critic_score', + ]; + + protected function year(): Attribute + { + return Attribute::make( + get: function () { + return $this->release_date?->year; + }, + ); + } + + protected function status(): Attribute + { + return Attribute::make( + get: function () { + return $this->release_date?->isFuture() + ? 'upcoming' + : 'released'; + }, + ); + } + + protected function rating(): Attribute + { + return Attribute::make( + get: function () { + return (float) Arr::get( + $this->attributes, + config('common.site.rating_column'), + ); + }, + ); + } + + public function getRatingAttribute() + { + return Arr::get($this->attributes, config('common.site.rating_column')); + } + + protected function voteCount(): Attribute + { + return Attribute::make( + get: function () { + $column = str_replace( + '_average', + '_count', + config('common.site.rating_column'), + ); + return Arr::get($this->attributes, $column) ?: 0; + }, + ); + } + + public function title(): BelongsTo + { + return $this->belongsTo(Title::class); + } + + public function comments(): MorphMany + { + return $this->morphMany(Comment::class, 'commentable')->orderBy( + 'created_at', + 'desc', + ); + } + + public function plays(): HasManyThrough + { + return $this->hasManyThrough(VideoPlay::class, Video::class); + } + + public function season(): BelongsTo + { + return $this->belongsTo(Season::class); + } + + public function toNormalizedArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->relationLoaded('title') + ? $this->title->name + : null, + 'image' => $this->poster, + 'model_type' => self::MODEL_TYPE, + ]; + } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'release_date' => $this->release_date, + 'popularity' => $this->popularity, + 'created_at' => $this->created_at->timestamp ?? '_null', + 'updated_at' => $this->updated_at->timestamp ?? '_null', + ]; + } + + public static function filterableFields(): array + { + return ['id', 'created_at', 'updated_at', 'release_date', 'popularity']; + } + + public static function getModelTypeAttribute(): string + { + return self::MODEL_TYPE; + } +} diff --git a/app/Models/Genre.php b/app/Models/Genre.php new file mode 100755 index 0000000..7c24ded --- /dev/null +++ b/app/Models/Genre.php @@ -0,0 +1,36 @@ +belongsToMany(Title::class); + } + + public function insertOrRetrieve( + Collection|array $tags, + ?string $type = 'custom', + ?int $userId = null, + ): Collection { + // genre table will not have type or user_id columns + return parent::insertOrRetrieve($tags, null, null); + } + + public function getByNames( + Collection $names, + ?string $type = null, + ?int $userId = null, + ): Collection { + return parent::getByNames($names, null, null); + } +} diff --git a/app/Models/Image.php b/app/Models/Image.php new file mode 100755 index 0000000..8b6af0e --- /dev/null +++ b/app/Models/Image.php @@ -0,0 +1,15 @@ +belongsToMany(Title::class); + } + + public function insertOrRetrieve( + Collection|array $tags, + ?string $type = 'custom', + ?int $userId = null, + ): Collection { + // keywords table will not have type or user_id columns + return parent::insertOrRetrieve($tags, null, null); + } + + public function getByNames( + Collection $names, + string $type = null, + int $userId = null, + ): Collection { + return parent::getByNames($names, null, null); + } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'display_name' => $this->display_name, + 'created_at' => $this->created_at->timestamp ?? '_null', + 'updated_at' => $this->updated_at->timestamp ?? '_null', + ]; + } +} diff --git a/app/Models/Listable.php b/app/Models/Listable.php new file mode 100755 index 0000000..e547c44 --- /dev/null +++ b/app/Models/Listable.php @@ -0,0 +1,17 @@ + 'integer', + 'order' => 'integer', + 'list_id' => 'integer', + 'listable_id' => 'integer', + ]; + + public const UPDATED_AT = null; +} diff --git a/app/Models/Movie.php b/app/Models/Movie.php new file mode 100755 index 0000000..5dee550 --- /dev/null +++ b/app/Models/Movie.php @@ -0,0 +1,19 @@ +where('is_series', false); + }); + } +} diff --git a/app/Models/NewsArticle.php b/app/Models/NewsArticle.php new file mode 100755 index 0000000..4373d16 --- /dev/null +++ b/app/Models/NewsArticle.php @@ -0,0 +1,69 @@ + slugify($value)); + } + + public function scopeCompact(Builder $query) + { + return $query->select([ + 'id', + 'image', + 'title', + 'slug', + 'byline', + 'source', + 'created_at', + ]); + } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'body' => $this->body, + 'slug' => $this->slug, + 'source' => $this->source, + 'created_at' => $this->created_at->timestamp ?? '_null', + 'updated_at' => $this->updated_at->timestamp ?? '_null', + ]; + } + + public function toNormalizedArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->title, + 'image' => $this->image, + 'description' => Str::limit($this->body, 100), + 'model_type' => static::MODEL_TYPE, + ]; + } + + public static function filterableFields(): array + { + return ['id', 'created_at', 'updated_at']; + } + + public static function getModelTypeAttribute(): string + { + return static::MODEL_TYPE; + } +} diff --git a/app/Models/Person.php b/app/Models/Person.php new file mode 100755 index 0000000..379c23b --- /dev/null +++ b/app/Models/Person.php @@ -0,0 +1,186 @@ + 'integer', + 'tmdb_id' => 'integer', + 'allow_update' => 'boolean', + 'fully_synced' => 'boolean', + 'adult' => 'boolean', + 'birth_date' => 'date', + 'death_date' => 'date', + ]; + + protected static function booted() + { + static::addGlobalScope('adult', function (Builder $builder) { + if (!config('tmdb.includeAdult')) { + $builder->where('adult', false); + } + }); + } + + public function scopeOrderByBirthDate(Builder $query, string $direction) + { + $query->orderByRaw( + "CASE WHEN birth_date IS NULL THEN 1 ELSE 0 END, birth_date $direction", + ); + } + + public static function firstOrCreateFromEncodedTmdbId( + string $encodedId, + ): static { + [$tmdbId] = static::decodeTmdbIdOrFail($encodedId); + return static::withoutGlobalScope('adult')->firstOrCreate([ + 'tmdb_id' => $tmdbId, + ]); + } + + public function maybeUpdateFromExternal(array $options = []): static|null + { + $tmdbImportingIsEnabled = + settings('content.people_provider') === 'tmdb' || + Arr::get($options, 'forceAutomation'); + + if ( + $tmdbImportingIsEnabled && + $this->needsUpdating($options['ignoreLastUpdate'] ?? false) + ) { + $data = app(TmdbApi::class)->getPerson($this); + if (!$data) { + return null; + } + app(StorePersonData::class)->execute($this, $data); + } + + return $this; + } + + public function needsUpdating($force = false): bool + { + if (!$this->exists || !$this->tmdb_id) { + return false; + } + + if ($force) { + return true; + } + + // sync every week + return $this->allow_update && + (!$this->updated_at || + $this->updated_at->lessThan(Carbon::now()->subWeek())); + } + + public static function getModelTypeAttribute(): string + { + return self::MODEL_TYPE; + } + + public function scopeCompact(Builder $query): Builder + { + return $query->select(['titles.id', 'titles.name', 'titles.poster']); + } + + public function credits(): BelongsToMany + { + return $this->morphedByMany(Title::class, 'creditable') + ->select( + 'titles.id', + 'is_series', + 'poster', + 'backdrop', + 'popularity', + 'name', + 'release_date', + 'tmdb_vote_average', + 'local_vote_average', + ) + ->withPivot(['id', 'job', 'department', 'order', 'character']) + ->orderBy('titles.release_date', 'desc'); + } + + public function episodeCredits(int $tileId = null): BelongsToMany + { + $query = $this->morphedByMany(Episode::class, 'creditable'); + if ($tileId) { + $query->where('episodes.title_id', $tileId); + } + $query + ->select( + 'episodes.id', + 'episodes.title_id', + 'name', + 'release_date', + 'season_number', + 'episode_number', + ) + ->withPivot(['job', 'department', 'order', 'character']) + ->orderBy('episodes.season_number', 'desc') + ->orderBy('episodes.episode_number', 'desc'); + return $query; + } + + /** + * @param int|null $tileId + * @return BelongsToMany + */ + public function seasonCredits($tileId = null) + { + $query = $this->morphedByMany(Season::class, 'creditable'); + if ($tileId) { + $query->where('seasons.title_id', $tileId); + } + $query + ->select('seasons.id', 'seasons.title_id') + ->withPivot(['job', 'department', 'order', 'character']) + ->orderBy('seasons.number', 'desc'); + return $query; + } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'created_at' => $this->created_at->timestamp ?? '_null', + 'updated_at' => $this->updated_at->timestamp ?? '_null', + ]; + } + + public static function filterableFields(): array + { + return ['id', 'created_at', 'updated_at']; + } + + public function toNormalizedArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'image' => $this->poster, + 'model_type' => self::MODEL_TYPE, + ]; + } +} diff --git a/app/Models/ProductionCountry.php b/app/Models/ProductionCountry.php new file mode 100755 index 0000000..f1a1c8e --- /dev/null +++ b/app/Models/ProductionCountry.php @@ -0,0 +1,34 @@ +belongsToMany(Title::class, 'country_title'); + } + + public function insertOrRetrieve( + Collection|array $tags, + ?string $type = 'custom', + ?int $userId = null, + ): Collection { + // countries table will not have type or user_id columns + return parent::insertOrRetrieve($tags, null, null); + } + + public function getByNames( + Collection $names, + string $type = null, + int $userId = null, + ): Collection { + return parent::getByNames($names, null, null); + } +} diff --git a/app/Models/ProfileLink.php b/app/Models/ProfileLink.php new file mode 100755 index 0000000..1f22f1c --- /dev/null +++ b/app/Models/ProfileLink.php @@ -0,0 +1,19 @@ + 'integer', + 'user_id' => 'integer', + 'reviewable_id' => 'integer', + 'score' => 'integer', + 'has_text' => 'boolean', + 'helpful_count' => 'integer', + 'not_helpful_count' => 'integer', + ]; + + public function feedback(): HasMany + { + return $this->hasMany(ReviewFeedback::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class)->select( + 'id', + 'first_name', + 'last_name', + 'email', + 'avatar', + ); + } + + public function reviewable(): MorphTo + { + return $this->morphTo(); + } + + public function reports(): HasMany + { + return $this->hasMany(ReviewReport::class); + } + + public function scopeOrderByMostHelpful(Builder $query): Builder + { + return $query->orderByWeightedScore( + 'desc', + 'helpful_count', + 'not_helpful_count', + ); + } + + public function scopeWithTextOnly(Builder $query): Builder + { + return $query->where('has_text', true); + } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'body' => $this->body, + 'created_at' => $this->created_at->timestamp ?? '_null', + 'updated_at' => $this->updated_at->timestamp ?? '_null', + ]; + } + + public static function filterableFields(): array + { + return ['id', 'created_at', 'updated_at']; + } + + public function toNormalizedArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'model_type' => self::MODEL_TYPE, + ]; + } + + public static function getModelTypeAttribute(): string + { + return self::MODEL_TYPE; + } +} diff --git a/app/Models/ReviewFeedback.php b/app/Models/ReviewFeedback.php new file mode 100755 index 0000000..de8cd60 --- /dev/null +++ b/app/Models/ReviewFeedback.php @@ -0,0 +1,30 @@ + 'integer', + 'user_id' => 'integer', + 'review_id' => 'integer', + 'is_helpful' => 'boolean', + ]; + + public function review(): BelongsTo + { + return $this->belongsTo(Review::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/ReviewReport.php b/app/Models/ReviewReport.php new file mode 100755 index 0000000..2c64d5b --- /dev/null +++ b/app/Models/ReviewReport.php @@ -0,0 +1,15 @@ + 'integer', + 'user_id' => 'integer', + 'review_id' => 'integer', + ]; +} diff --git a/app/Models/Season.php b/app/Models/Season.php new file mode 100755 index 0000000..13281b7 --- /dev/null +++ b/app/Models/Season.php @@ -0,0 +1,108 @@ + 'integer', + 'fully_synced' => 'boolean', + 'episodes_count' => 'integer', + 'number' => 'integer', + 'release_date' => 'date', + ]; + + public function episodes(): HasMany + { + return $this->hasMany(Episode::class); + } + + public function title(): BelongsTo + { + return $this->belongsTo(Title::class); + } + + public function getModelTypeAttribute(): string + { + return self::MODEL_TYPE; + } + + public function findEpisode(int $number): Episode|null + { + return $this->episodes() + ->where('episode_number', $number) + ->firstOrFail(); + } + + public function maybeUpdateFromExternal( + Title $title, + array $options = [], + ): self { + if ($this->needsUpdating($title, $options)) { + $data = app(TmdbApi::class)->getSeason($title, $this->number); + if ($data) { + app(StoreSeasonData::class)->execute($title, $data); + $this->refresh(); + } + } + return $this; + } + + protected function needsUpdating(Title $title, array $options = []): bool + { + $isFullySynced = $options['forceAutomation'] ?? $this->fully_synced; + $tmdbImportingIsEnabled = + settings('content.title_provider') === 'tmdb' || + Arr::get($options, 'forceAutomation'); + + if (!$this->exists || !$title->tmdb_id) { + return false; + } + + // series ended and this season is already fully updated from external site + if ($title->series_ended && $isFullySynced) { + return false; + } + + // season is fully synced, and it's not the latest season + if ($isFullySynced && $title->season_count > $this->number) { + return false; + } + + if ( + !$tmdbImportingIsEnabled && + // might need to fetch title seasons, even if automation is disabled because they can't be + // fetched when importing multiple titles without hitting tmdb api rate limits + !settings('content.force_season_update') + ) { + return false; + } + + if (!$isFullySynced) { + return true; + } + + if (Arr::get($options, 'ignoreLastUpdate')) { + return true; + } + + return !$this->updated_at || + $this->updated_at->lessThan(Carbon::now()->subWeek()); + } +} diff --git a/app/Models/Series.php b/app/Models/Series.php new file mode 100755 index 0000000..453d66b --- /dev/null +++ b/app/Models/Series.php @@ -0,0 +1,19 @@ +where('is_series', true); + }); + } +} diff --git a/app/Models/Title.php b/app/Models/Title.php new file mode 100755 index 0000000..abaed65 --- /dev/null +++ b/app/Models/Title.php @@ -0,0 +1,361 @@ + 'integer', + 'allow_update' => 'boolean', + 'series_ended' => 'boolean', + 'is_series' => 'boolean', + 'tmdb_vote_count' => 'integer', + 'runtime' => 'integer', + 'views' => 'integer', + 'popularity' => 'integer', + 'tmdb_vote_average' => 'float', + 'local_vote_average' => 'float', + 'fully_synced' => 'boolean', + 'adult' => 'boolean', + 'rating' => 'float', + 'vote_count' => 'integer', + 'seasons_count' => 'integer', + 'release_date' => 'date', + ]; + + protected static function booted() + { + static::addGlobalScope('adult', function (Builder $builder) { + if (!config('tmdb.includeAdult')) { + $builder->where('adult', false); + } + }); + } + + public function findSeason(int $number): Season + { + return $this->seasons() + ->where('number', $number) + ->firstOrFail(); + } + + public function findEpisode(int $season, int $episode): Episode + { + return $this->episodes() + ->where('season_number', $season) + ->where('episode_number', $episode) + ->firstOrFail(); + } + + public function maybeUpdateFromExternal(array $options = []): static|null + { + $tmdbImportingIsEnabled = + settings('content.title_provider') === 'tmdb' || + Arr::get($options, 'forceAutomation'); + $needsUpdating = $this->needsUpdating( + $options['ignoreLastUpdate'] ?? false, + ); + + // first update title itself, if needed + if ($tmdbImportingIsEnabled && $needsUpdating) { + $data = app(TmdbApi::class)->getTitle($this); + if (!$data) { + return null; + } + + app(StoreTitleData::class)->execute($this, $data); + } + + // then update 3 last seasons + if ( + $needsUpdating && + $this->is_series && + Arr::get($options, 'updateLast3Seasons') && + (settings('content.force_season_update') || $tmdbImportingIsEnabled) + ) { + $this->seasons() + ->orderBy('number', 'desc') + ->take(3) + ->get() + ->each( + fn(Season $season) => $season->maybeUpdateFromExternal( + $this, + $options, + ), + ); + } + + return $this; + } + + protected function needsUpdating($force = false): bool + { + if (!$this->tmdb_id || !$this->exists) { + return false; + } + + if ($force) { + return true; + } + + // only partial data was fetched + if ( + is_null($this->release_date) || + (is_null($this->runtime) && + is_null($this->revenue) && + is_null($this->country) && + is_null($this->budget) && + is_null($this->imdb_id)) + ) { + return true; + } + + // sync every week + return $this->allow_update && + $this->updated_at->lessThan(Carbon::now()->subWeek()); + } + + public static function firstOrCreateFromEncodedTmdbId( + string $encodedId, + ): static { + [$tmdbId, $type] = static::decodeTmdbIdOrFail($encodedId); + + if (!$tmdbId || !$type) { + throw new ModelNotFoundException(); + } + + return static::withoutGlobalScope('adult')->firstOrCreate([ + 'tmdb_id' => $tmdbId, + 'is_series' => $type === Title::SERIES_TYPE, + ]); + } + + protected function rating(): Attribute + { + return Attribute::make( + get: function () { + return (float) Arr::get( + $this->attributes, + config('common.site.rating_column'), + ); + }, + ); + } + + protected function status(): Attribute + { + return Attribute::make( + get: function () { + if ($this->release_date?->isFuture()) { + return 'upcoming'; + } elseif ($this->is_series) { + return $this->series_ended ? 'ended' : 'ongoing'; + } else { + return 'released'; + } + }, + ); + } + + protected function voteCount(): Attribute + { + return Attribute::make( + get: function () { + $column = str_replace( + '_average', + '_count', + config('common.site.rating_column'), + ); + return Arr::get($this->attributes, $column) ?: 0; + }, + ); + } + + protected function year(): Attribute + { + return Attribute::make( + get: function () { + return $this->release_date?->year; + }, + ); + } + + public function plays(): HasManyThrough + { + return $this->hasManyThrough(VideoPlay::class, Video::class); + } + + public function genres(): BelongsToMany + { + return $this->belongsToMany(Genre::class); + } + + public function keywords(): BelongsToMany + { + return $this->belongsToMany(Keyword::class); + } + + public function productionCountries(): BelongsToMany + { + return $this->belongsToMany(ProductionCountry::class, 'country_title'); + } + + public function scopeCompact(Builder $query): Builder + { + return $query->select([ + 'titles.id', + 'titles.name', + 'titles.poster', + 'titles.backdrop', + ]); + } + + public function images(): MorphMany + { + return $this->morphMany(Image::class, 'model') + ->select(['id', 'model_id', 'model_type', 'url', 'type', 'source']) + ->orderBy('order', 'asc'); + } + + public function newsArticles(): MorphToMany + { + return $this->morphToMany( + NewsArticle::class, + 'model', + 'news_article_models', + 'model_id', + 'article_id', + )->orderBy('created_at', 'desc'); + } + + public function reviews(): MorphMany + { + return $this->morphMany(Review::class, 'reviewable'); + } + + public function comments(): MorphMany + { + return $this->morphMany(Comment::class, 'commentable') + ->orderBy('created_at', 'desc') + ->orderByWeightedScore(); + } + + public function seasons(): HasMany + { + return $this->hasMany(Season::class); + } + + public function season(): HasOne + { + return $this->hasOne(Season::class); + } + + public function episodes(): HasMany + { + return $this->hasMany(Episode::class); + } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'original_title' => $this->original_title, + 'release_date' => $this->release_date, + 'popularity' => $this->popularity, + 'created_at' => $this->created_at->timestamp ?? '_null', + 'updated_at' => $this->updated_at->timestamp ?? '_null', + ]; + } + + public static function filterableFields(): array + { + return ['id', 'created_at', 'updated_at', 'release_date', 'popularity']; + } + + public function toNormalizedArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'description' => $this->release_date?->format('Y'), + 'image' => $this->poster + ? preg_replace('/original|w1280/', 'w92', $this->poster) + : null, + 'model_type' => self::MODEL_TYPE, + ]; + } + + public static function getModelTypeAttribute(): string + { + return self::MODEL_TYPE; + } + + public function resolveRouteBinding($value, $field = null): static + { + if (is_numeric($value) || ctype_digit($value)) { + return $this->findOrFail($value); + } + + [$tmdbId, $type] = static::decodeTmdbIdOrFail($value); + + if (!$tmdbId || !$type) { + throw new ModelNotFoundException(); + } + + return static::where('tmdb_id', $tmdbId) + ->where('is_series', $type === Title::SERIES_TYPE) + ->firstOrFail(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100755 index 0000000..58f9757 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,47 @@ +hasOne(Channel::class) + ->where('type', 'list') + ->where('name', 'watchlist'); + } + + public function reviews(): HasMany + { + return $this->hasMany(Review::class); + } + + public function lists(): HasMany + { + return $this->hasMany(Channel::class)->where('type', 'list'); + } + + public function comments(): HasMany + { + return $this->hasMany(Comment::class); + } + + public function profile(): HasOne + { + return $this->hasOne(UserProfile::class); + } + + public function links(): MorphMany + { + return $this->morphMany(ProfileLink::class, 'linkeable'); + } +} diff --git a/app/Models/UserProfile.php b/app/Models/UserProfile.php new file mode 100755 index 0000000..b87e92b --- /dev/null +++ b/app/Models/UserProfile.php @@ -0,0 +1,17 @@ + 'integer', + 'positive_votes' => 'integer', + 'order' => 'integer', + 'approved' => 'boolean', + 'title_id' => 'integer', + 'id' => 'integer', + 'user_id' => 'integer', + ]; + + public function title(): BelongsTo + { + return $this->belongsTo(Title::class)->select([ + 'id', + 'name', + 'poster', + 'backdrop', + 'is_series', + ]); + } + + public function votes(): HasMany + { + return $this->hasMany(VideoVote::class); + } + + public function reports(): HasMany + { + return $this->hasMany(VideoReport::class); + } + + public function captions(): HasMany + { + return $this->hasMany(VideoCaption::class)->orderBy('order', 'asc'); + } + + public function plays(): HasMany + { + return $this->hasMany(VideoPlay::class); + } + + public function latestPlay(): HasOne + { + return $this->hasOne(VideoPlay::class)->orderBy('created_at', 'desc'); + } + + public function episode(): BelongsTo + { + return $this->belongsTo(Episode::class); + } + + public function comments(): MorphMany + { + return $this->morphMany(Comment::class, 'commentable') + ->orderBy('created_at', 'desc') + ->orderByWeightedScore(); + } + + public function scopeOrderByMostUpvotes(Builder $query): Builder + { + return $query->orderByWeightedScore('desc'); + } + + public function getScoreAttribute() + { + $total = $this->positive_votes + $this->negative_votes; + if (!$total) { + return null; + } + return round(($this->positive_votes / $total) * 100); + } + + public function scopeApplySelectedSort(Builder $query): Builder + { + [$col, $dir] = explode( + ':', + settings('streaming.default_sort', 'order:asc'), + ); + + if ($col === 'score') { + $query->orderByWeightedScore(); + } elseif ($col === 'order') { + $query->orderByRaw('`category` = "trailer" desc, `order` asc'); + } else { + $query + ->orderBy(DB::raw('`category` = "trailer"'), 'desc') + ->orderBy($col, $dir); + } + + return $query; + } + + public function scopeFromConfiguredCategory(Builder $builder): Builder + { + $contentType = settings('streaming.video_panel_content'); + + if ($contentType === 'full') { + $builder->where('category', 'full'); + } elseif ($contentType === 'short') { + $builder->where('category', '!=', 'full'); + } elseif ($contentType !== 'all') { + $builder->where('category', $contentType); + } + + return $builder; + } + + public function toSearchableArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'created_at' => $this->created_at->timestamp ?? '_null', + 'updated_at' => $this->updated_at->timestamp ?? '_null', + ]; + } + + public static function filterableFields(): array + { + return ['id', 'created_at', 'updated_at']; + } + + public function toNormalizedArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'image' => $this->thumbnail, + 'model_type' => self::MODEL_TYPE, + ]; + } + + public static function getModelTypeAttribute(): string + { + return self::MODEL_TYPE; + } +} diff --git a/app/Models/VideoCaption.php b/app/Models/VideoCaption.php new file mode 100755 index 0000000..ac8282f --- /dev/null +++ b/app/Models/VideoCaption.php @@ -0,0 +1,22 @@ + 'integer', + 'user_id' => 'integer', + 'video_id' => 'integer', + ]; + + public function video(): BelongsTo + { + return $this->belongsTo(Video::class); + } +} diff --git a/app/Models/VideoPlay.php b/app/Models/VideoPlay.php new file mode 100755 index 0000000..048b87d --- /dev/null +++ b/app/Models/VideoPlay.php @@ -0,0 +1,23 @@ + 'integer', 'video_id' => 'integer']; + + public function scopeForCurrentUser(Builder $builder): Builder + { + if (Auth::check()) { + return $builder->where('user_id', Auth::id()); + } else { + return $builder->where('ip', getIp()); + } + } +} diff --git a/app/Models/VideoReport.php b/app/Models/VideoReport.php new file mode 100755 index 0000000..8416d96 --- /dev/null +++ b/app/Models/VideoReport.php @@ -0,0 +1,15 @@ + 'integer', + 'user_id' => 'integer', + 'comment_id' => 'integer', + ]; +} diff --git a/app/Models/VideoVote.php b/app/Models/VideoVote.php new file mode 100755 index 0000000..e300dc1 --- /dev/null +++ b/app/Models/VideoVote.php @@ -0,0 +1,9 @@ +authorizePermission($user, 'lists.view'); + } + + return $this->authorizePermission($user, 'channels.update'); + } + + public function show(?User $user, Channel $channel) + { + if ($channel->user_id && $channel->user_id === $user->id) { + return true; + } + + if ($channel->type === 'channel') { + return $this->authorizePermission($user, 'titles.view'); + } else { + // if list not public and user is not owner, deny access + if (!$channel->public && !$channel->user_id === $user?->id) { + return false; + } + // require "lists.view" permission always, so users can be + // blocked completely from lists functionality if not subscribed + return $this->authorizePermission($user, 'lists.view'); + } + } + + public function store(User $user, string $channelType = null) + { + if ($channelType === 'list') { + return $this->hasPermission($user, 'lists.create'); + } + return $this->hasPermission($user, 'channels.create'); + } + + public function update(User $user, Channel $channel) + { + if ($channel->user_id && $channel->user_id === $user->id) { + return true; + } + + if ($channel->type === 'list') { + return $this->hasPermission($user, 'lists.update'); + } + + return $this->hasPermission($user, 'channels.update'); + } + + public function destroy(User $user, Collection $channels = null) + { + $type = $channels?->first()['type'] ?? 'channel'; + + if ($type === 'list' && $this->hasPermission($user, 'lists.delete')) { + return true; + } + + if ( + $type === 'channel' && + $this->hasPermission($user, 'channels.delete') + ) { + return true; + } + + return collect($channels)->every( + fn(Channel $list) => $list->user_id === $user->id, + ); + } +} diff --git a/app/Policies/NewsArticlePolicy.php b/app/Policies/NewsArticlePolicy.php new file mode 100755 index 0000000..5d64c05 --- /dev/null +++ b/app/Policies/NewsArticlePolicy.php @@ -0,0 +1,36 @@ +hasPermission('news.view'); + } + + public function show(User $user) + { + return $user->hasPermission('news.view'); + } + + public function store(User $user) + { + return $user->hasPermission('news.create'); + } + + public function update(User $user) + { + return $user->hasPermission('news.update'); + } + + public function destroy(User $user) + { + return $user->hasPermission('news.delete'); + } +} diff --git a/app/Policies/PersonPolicy.php b/app/Policies/PersonPolicy.php new file mode 100755 index 0000000..26a86e4 --- /dev/null +++ b/app/Policies/PersonPolicy.php @@ -0,0 +1,36 @@ +hasPermission('people.view'); + } + + public function show(User $user) + { + return $user->hasPermission('people.view'); + } + + public function store(User $user) + { + return $user->hasPermission('people.create'); + } + + public function update(User $user) + { + return $user->hasPermission('people.update'); + } + + public function destroy(User $user) + { + return $user->hasPermission('people.delete'); + } +} diff --git a/app/Policies/ReviewPolicy.php b/app/Policies/ReviewPolicy.php new file mode 100755 index 0000000..2798978 --- /dev/null +++ b/app/Policies/ReviewPolicy.php @@ -0,0 +1,40 @@ +hasPermission('reviews.view'); + } + + public function show(User $user) + { + return $user->hasPermission('reviews.view'); + } + + public function store(User $user) + { + return $user->hasPermission('reviews.create'); + } + + public function update(User $user) + { + return $user->hasPermission('reviews.update'); + } + + public function destroy(User $user, Collection $reviews) + { + if ($user->hasPermission('reviews.delete')) return true; + + return $reviews->every(fn(Review $review) => $user->id && $user->id === $review->user_id); + } +} diff --git a/app/Policies/TitlePolicy.php b/app/Policies/TitlePolicy.php new file mode 100755 index 0000000..f8532fa --- /dev/null +++ b/app/Policies/TitlePolicy.php @@ -0,0 +1,36 @@ +hasPermission('titles.view'); + } + + public function show(User $user) + { + return $user->hasPermission('titles.view'); + } + + public function store(User $user) + { + return $user->hasPermission('titles.create'); + } + + public function update(User $user) + { + return $user->hasPermission('titles.update'); + } + + public function destroy(User $user) + { + return $user->hasPermission('titles.delete'); + } +} diff --git a/app/Policies/VideoPolicy.php b/app/Policies/VideoPolicy.php new file mode 100755 index 0000000..4a6b5d8 --- /dev/null +++ b/app/Policies/VideoPolicy.php @@ -0,0 +1,50 @@ +hasPermission('videos.rate'); + } + + public function index(User $user, int $userId = null) + { + return $user->hasPermission('videos.view') || $user->id === $userId; + } + + public function show(User $user, Video $video = null) + { + return $user->hasPermission('videos.view') || ($video && $video->user_id === $user->id); + } + + public function store(User $user) + { + return $user->hasPermission('videos.create'); + } + + public function update(User $user) + { + return $user->hasPermission('videos.update'); + } + + public function destroy(User $user, $videoIds) + { + if ($user->hasPermission('videos.delete')) { + return true; + } else { + $dbCount = app(Video::class) + ->whereIn('id', $videoIds) + ->where('user_id', $user->id) + ->count(); + return $dbCount === (is_countable($videoIds) ? count($videoIds) : 0); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100755 index 0000000..b7f077b --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,72 @@ +app->bind(BootstrapData::class, AppBootstrapData::class); + + Relation::enforceMorphMap([ + Title::MODEL_TYPE => Title::class, + 'movie' => Title::class, + 'series' => Title::class, + Season::MODEL_TYPE => Season::class, + Episode::MODEL_TYPE => Episode::class, + Person::MODEL_TYPE => Person::class, + NewsArticle::MODEL_TYPE => NewsArticle::class, + ]); + + Model::preventLazyLoading(!$this->app->isProduction()); + + // This will only work when loading from collection, because we need access to channel config + Channel::resolveRelationUsing( + 'items', + fn(Channel $channel) => $channel->morphedByMany( + modelTypeToNamespace( + $channel->config['contentModel'] ?? Title::class, + ), + 'channelable', + ), + ); + + if (config('common.site.disable_scout_auto_sync')) { + foreach (ImportRecordsIntoScout::getSearchableModels() as $model) { + $model::disableSearchSyncing(); + } + } + } + + public function register(): void + { + // bind analytics + $this->app->bind( + GetAnalyticsHeaderDataAction::class, + GetAnalyticsHeaderData::class, + ); + + $this->app->bind(AppUrlGenerator::class, UrlGenerator::class); + + $this->app->bind(ValueLists::class, AppValueLists::class); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php new file mode 100755 index 0000000..3ffc67d --- /dev/null +++ b/app/Providers/AuthServiceProvider.php @@ -0,0 +1,25 @@ +registerPolicies(); + } +} diff --git a/app/Providers/BroadcastServiceProvider.php b/app/Providers/BroadcastServiceProvider.php new file mode 100755 index 0000000..5eae52f --- /dev/null +++ b/app/Providers/BroadcastServiceProvider.php @@ -0,0 +1,21 @@ + 'secure', 'middleware' => 'web']); + + require base_path('routes/channels.php'); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php new file mode 100755 index 0000000..aebb849 --- /dev/null +++ b/app/Providers/EventServiceProvider.php @@ -0,0 +1,38 @@ + [ + CreateWatchlist::class, + ], + UsersDeleted::class => [ + DeleteUserChannels::class + ], + ]; + + /** + * Register any events for your application. + * + * @return void + */ + public function boot() + { + parent::boot(); + + // + } +} diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php new file mode 100755 index 0000000..ff3dd21 --- /dev/null +++ b/app/Providers/HorizonServiceProvider.php @@ -0,0 +1,44 @@ +email === 'Ic0OdCIodqz8q1r@demo.com'; + } else { + return $user->hasPermission('admin'); + } + }); + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php new file mode 100755 index 0000000..e0bfa72 --- /dev/null +++ b/app/Providers/RouteServiceProvider.php @@ -0,0 +1,85 @@ +mapApiRoutes(); + + $this->mapWebRoutes(); + + // + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + * + * @return void + */ + protected function mapWebRoutes() + { + Route::middleware('web') + ->namespace($this->namespace) + ->group(base_path('routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + * + * @return void + */ + protected function mapApiRoutes() + { + Route::prefix('api') + ->middleware('api') + ->namespace($this->namespace) + ->group(base_path('routes/api.php')); + } +} diff --git a/app/Services/Admin/GetAnalyticsHeaderData.php b/app/Services/Admin/GetAnalyticsHeaderData.php new file mode 100755 index 0000000..8a29dcf --- /dev/null +++ b/app/Services/Admin/GetAnalyticsHeaderData.php @@ -0,0 +1,113 @@ + [ + [ + 'tag' => 'path', + 'attr' => [ + 'd' => + 'M9 13.75c-2.34 0-7 1.17-7 3.5V19h14v-1.75c0-2.33-4.66-3.5-7-3.5zM4.34 17c.84-.58 2.87-1.25 4.66-1.25s3.82.67 4.66 1.25H4.34zM9 12c1.93 0 3.5-1.57 3.5-3.5S10.93 5 9 5 5.5 6.57 5.5 8.5 7.07 12 9 12zm0-5c.83 0 1.5.67 1.5 1.5S9.83 10 9 10s-1.5-.67-1.5-1.5S8.17 7 9 7zm7.04 6.81c1.16.84 1.96 1.96 1.96 3.44V19h4v-1.75c0-2.02-3.5-3.17-5.96-3.44zM15 12c1.93 0 3.5-1.57 3.5-3.5S16.93 5 15 5c-.54 0-1.04.13-1.5.35.63.89 1 1.98 1 3.15s-.37 2.26-1 3.15c.46.22.96.35 1.5.35z', + ], + ], + ], + 'name' => __('New users'), + ], + (new ValueMetric( + User::query(), + dateRange: $params['dateRange'], + ))->count(), + ), + + array_merge( + [ + 'icon' => [ + [ + 'tag' => 'path', + 'attr' => [ + 'd' => + 'M4 6.47 5.76 10H20v8H4V6.47M22 4h-4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4z', + ], + ], + ], + 'name' => __('New titles'), + ], + (new ValueMetric( + Title::query(), + dateRange: $params['dateRange'], + ))->count(), + ), + + array_merge( + [ + 'icon' => [ + [ + 'tag' => 'path', + 'attr' => [ + 'd' => + 'M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27z', + ], + ], + ], + 'name' => __('New ratings'), + ], + (new ValueMetric( + Review::query(), + dateRange: $params['dateRange'], + ))->count(), + ), + array_merge( + [ + 'icon' => [ + [ + 'tag' => 'path', + 'attr' => [ + 'd' => + 'M10 8.64 15.27 12 10 15.36V8.64M8 5v14l11-7L8 5z', + ], + ], + ], + 'name' => __('Video plays'), + ], + (new ValueMetric( + VideoPlay::query(), + dateRange: $params['dateRange'], + ))->count(), + ), + + array_merge( + [ + 'icon' => [ + [ + 'tag' => 'path', + 'attr' => [ + 'd' => + 'M4 4h16v12H5.17L4 17.17V4m0-2c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2H4zm2 10h8v2H6v-2zm0-3h12v2H6V9zm0-3h12v2H6V6z', + ], + ], + ], + 'name' => __('New comments'), + ], + (new ValueMetric( + Comment::query(), + dateRange: $params['dateRange'], + ))->count(), + ), + ]; + } +} diff --git a/app/Services/AppBootstrapData.php b/app/Services/AppBootstrapData.php new file mode 100755 index 0000000..117995c --- /dev/null +++ b/app/Services/AppBootstrapData.php @@ -0,0 +1,15 @@ +data['settings']['tmdb_is_setup'] = !is_null(config('services.tmdb.key')); + + return $this; + } +} diff --git a/app/Services/ChannelPresets.php b/app/Services/ChannelPresets.php new file mode 100755 index 0000000..b4034c1 --- /dev/null +++ b/app/Services/ChannelPresets.php @@ -0,0 +1,50 @@ + 'Movie database', + 'preset' => 'database', + 'description' => + 'Channel preset for a movie database site similar to IMDb', + ], + [ + 'name' => 'Anime', + 'preset' => 'anime', + 'description' => + 'Channel preset for an anime based site similar to Crunchyroll', + ], + [ + 'name' => 'Streaming', + 'preset' => 'streaming', + 'description' => + 'Channel preset for a streaming site similar to Netflix', + ], + ]; + } + + public function apply(string $preset): void + { + $presetConfig = match ($preset) { + 'anime' => resource_path('defaults/channels/anime-channels.json'), + 'streaming' => resource_path( + 'defaults/channels/streaming-channels.json', + ), + default => resource_path( + 'defaults/channels/database-channels.json', + ), + }; + + (new GenerateChannelsFromConfig())->execute([ + resource_path('defaults/channels/shared-channels.json'), + $presetConfig, + ]); + } +} diff --git a/app/Services/Data/News/ImdbNewsProvider.php b/app/Services/Data/News/ImdbNewsProvider.php new file mode 100755 index 0000000..bc9b6d9 --- /dev/null +++ b/app/Services/Data/News/ImdbNewsProvider.php @@ -0,0 +1,121 @@ + + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' . + 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 ' . + 'Safari/537.36', + ]) + ->get('https://www.imdb.com/news/top') + ->getBody() + ->getContents(); + $strippedHtml = preg_replace( + '/(.*?)<\/script>/is', + '', + $html, + ); + + $crawler = new Crawler($strippedHtml); + + // grab every news article on the page + foreach ( + $crawler->filter( + '[data-testid="sub-section-news-card-section"] .ipc-list-card', + ) + as $k => $node + ) { + $articleCrawler = new Crawler($node); + + // extract related people and title ids from article + $links = $articleCrawler->filter('a')->extract(['href']); + $imdbTitleIds = []; + $imdbPersonIds = []; + foreach ($links as $href) { + preg_match('/\/title\/(tt[0-9]+)\//', $href, $titleMatches); + preg_match('/\/name\/(nm[0-9]+)\//', $href, $nameMatches); + if (isset($titleMatches[1])) { + $imdbTitleIds[] = $titleMatches[1]; + } + if (isset($nameMatches[1])) { + $imdbPersonIds[] = $nameMatches[1]; + } + } + + $date = head( + $articleCrawler + ->filter('.ipc-inline-list li') + ->extract(['_text']), + ); + $byline = head( + $articleCrawler + ->filter('.ipc-inline-list li') + ->eq(1) + ->extract(['_text']), + ); + + $sourceUrl = last( + $articleCrawler->filter('.ipc-link')->extract(['href']), + ); + if (!isset(parse_url($sourceUrl)['scheme'])) { + $sourceUrl = "https://imdb.com{$sourceUrl}"; + } + $img = head($articleCrawler->filter('img')->extract(['src'])); + $body = trim( + $articleCrawler->filter('.ipc-html-content-inner-div')->html(), + ); + + $body = preg_replace( + '/
.+?<\/div>/', + '', + $body, + ); + $body = strip_tags($body, '
'); + + if (!$img) { + continue; + } + + // transform each news article into array + $compiledNews[$k] = [ + 'title' => trim( + head( + $articleCrawler + ->filter('.ipc-link') + ->extract(['_text']), + ), + ), + 'body' => $body, + 'imdb_title_ids' => $imdbTitleIds, + 'imdb_person_ids' => $imdbPersonIds, + 'date' => trim($date), + 'source' => trim( + last( + $articleCrawler + ->filter('.ipc-link') + ->extract(['_text']), + ), + ), + 'source_url' => $sourceUrl, + 'byline' => str_replace('by ', '', trim($byline)), + 'image' => preg_replace( + '/([A-Z]+)([0-9]+)_CR([0-9]+),([0-9]+),100,150/', + '${1}400_CR$3,$4,270,400', + $img, + ), + ]; + } + + return $compiledNews; + } +} diff --git a/app/Services/Data/Tmdb/TmdbApi.php b/app/Services/Data/Tmdb/TmdbApi.php new file mode 100755 index 0000000..963975f --- /dev/null +++ b/app/Services/Data/Tmdb/TmdbApi.php @@ -0,0 +1,225 @@ +http = new HttpClient(['exceptions' => true]); + + $this->language = $this->settings->get( + 'tmdb.language', + self::DEFAULT_TMDB_LANGUAGE, + ); + $this->includeAdult = $this->settings->get('tmdb.includeAdult', false); + } + + public function getPerson(Person $person): array|null + { + $appends = []; + + // only import filmography if it's set by user + if ($this->settings->get('content.automate_filmography')) { + $appends[] = 'combined_credits'; + } + + $response = $this->call("person/{$person->tmdb_id}", [ + 'append_to_response' => implode(',', $appends), + ]); + + // person does not exist anymore on themoviedb + if ( + Arr::get($response, 'success') === false && + Arr::get($response, 'status_code') === 34 + ) { + if (config('common.site.tmdb_delete_when_sync')) { + app(DeletePeople::class)->execute([$person->id]); + return null; + } else { + return []; + } + } + + $response['fully_synced'] = true; + + return app(TransformData::class) + ->execute([$response]) + ->first(); + } + + public function getSeason(Title $title, $seasonNumber) + { + if (!$title->tmdb_id) { + return []; + } + + $response = $this->call("tv/{$title->tmdb_id}/season/{$seasonNumber}", [ + 'append_to_response' => 'credits', + ]); + + // season does not exist anymore on themoviedb + if ( + Arr::get($response, 'success') === false && + Arr::get($response, 'status_code') === 34 + ) { + if (config('common.site.tmdb_delete_when_sync')) { + $seasonId = $title + ->seasons() + ->where('number', $seasonNumber) + ->value('id'); + app(DeleteSeasons::class)->execute([$seasonId]); + return null; + } else { + return []; + } + } + + $data = app(TransformData::class) + ->execute([$response]) + ->first(); + $data['fully_synced'] = true; + + return $data; + } + + public function getTitle(Title $title): array|null + { + $appends = [ + 'credits', + 'external_ids', + 'images', + 'content_ratings', + 'keywords', + 'release_dates', + 'videos', + 'seasons', + ]; + + $uri = $title->is_series ? 'tv' : 'movie'; + + $response = $this->call("$uri/{$title->tmdb_id}", [ + 'append_to_response' => implode(',', $appends), + ]); + + // title does not exist anymore on themoviedb + if ( + Arr::get($response, 'success') === false && + Arr::get($response, 'status_code') === 34 + ) { + if (config('common.site.tmdb_delete_when_sync')) { + app(DeleteTitles::class)->execute([$title->id]); + return null; + } else { + return []; + } + } + + $data = app(TransformData::class) + ->execute([$response]) + ->first(); + + // fall back to english videos if there are no videos in the current language + if (!Str::startsWith($this->language, 'en') && empty($data['videos'])) { + $videos = $this->call("$uri/{$title->tmdb_id}/videos", [ + 'language' => 'en-US', + ]); + $videos = app(TransformData::class)->formatVideos( + $videos['results'], + ); + $data['videos'] = $videos; + } + + $data['fully_synced'] = true; + return $data; + } + + public function search(string $query, array $params = []): Collection + { + $response = $this->call('search/multi', ['query' => $query]); + $results = app(TransformData::class)->execute($response['results']); + + $type = Arr::get($params, 'type'); + $limit = Arr::get($params, 'limit', 8); + + if ($type) { + $results = $results->filter( + fn($result) => $result['type'] === $type, + ); + } + + return $results + ->sortByDesc('popularity') + ->slice(0, $limit) + ->values(); + } + + public function browse($page = 1, $type = 'movie', $queryParams = []): array + { + if ($page > 500) { + throw new Exception('Maximum page is 500'); + } + + if ($type === 'series') { + $type = 'tv'; + } + + $apiParams = array_merge( + ['sort_by' => 'popularity.desc', 'page' => $page], + $queryParams, + ); + + $response = $this->call("discover/$type", $apiParams); + $response['results'] = app(TransformData::class)->execute( + $response['results'], + ); + + return $response; + } + + public function trendingPeople(): Collection + { + $response = $this->call('person/popular'); + return app(TransformData::class)->execute($response['results']); + } + + protected function call(string $uri, array $queryParams = []): array + { + $key = config('services.tmdb.key'); + $url = self::TMDB_BASE . "$uri?api_key=$key"; + + $queryParams = array_merge( + [ + // need to send "true" and not "1" otherwise tmdb will not work + 'include_adult' => $this->includeAdult ? 'true' : 'false', + 'language' => $this->language, + 'region' => 'US', + 'include_image_language' => 'en,null', + ], + $queryParams, + ); + $url .= '&' . urldecode(http_build_query($queryParams)); + return $this->http->get($url, [ + 'verify' => false, + ]); + } +} diff --git a/app/Services/Data/Tmdb/TransformData.php b/app/Services/Data/Tmdb/TransformData.php new file mode 100755 index 0000000..18ad911 --- /dev/null +++ b/app/Services/Data/Tmdb/TransformData.php @@ -0,0 +1,522 @@ +map( + fn($mediaItem) => $this->transformMediaItem($mediaItem), + ); + } + + public function transformMediaItem($mediaItem): ?array + { + $type = $this->getType($mediaItem); + if ($type === Person::MODEL_TYPE) { + return $this->transformPerson($mediaItem); + } elseif ($type === Episode::MODEL_TYPE) { + return $this->transformEpisode($mediaItem); + } elseif ($type === Season::MODEL_TYPE) { + return $this->transformSeason($mediaItem); + } else { + return $this->transformTitle($mediaItem, $type); + } + } + + private function transformTitle($data, $type): array + { + $releaseKey = + $type === Title::MOVIE_TYPE ? 'release_date' : 'first_air_date'; + $releaseDate = $this->getReleaseDate($releaseKey, $data); + $name = $this->getTitle($data); + + $transformed = [ + 'id' => $this->encodeTmdbId('tmdb', $type, $data['id']), + 'is_series' => $type === Title::SERIES_TYPE, + 'model_type' => Title::MODEL_TYPE, + 'poster' => $this->getPoster(Arr::get($data, 'poster_path')), + 'release_date' => $releaseDate, + 'cast' => $this->getCredits($data), + 'name' => $name, + 'description' => $data['overview'], + 'tmdb_vote_count' => $data['vote_count'] ?? null, + 'tmdb_vote_average' => isset($data['vote_average']) + ? round($data['vote_average'], 1) + : null, + 'original_title' => $this->getOriginalName($data), + 'popularity' => Arr::get($data, 'popularity'), + 'language' => Arr::get($data, 'original_language'), + 'certification' => $this->getCertification($data, $type), + 'countries' => $this->getCountries($data), + 'tagline' => Arr::get($data, 'tagline'), + 'budget' => Arr::get($data, 'budget') ?: null, + 'revenue' => Arr::get($data, 'revenue') ?: null, + 'runtime' => $this->getRuntime($data), + 'videos' => $this->formatVideos( + Arr::get($data, 'videos.results', []), + ), + 'images' => $this->transformImages($data), + 'backdrop' => $this->getBackdrop($data), + 'genres' => $this->getGenres($data), + 'imdb_id' => Arr::get($data, 'external_ids.imdb_id') ?: null, + 'tmdb_id' => $data['id'], + 'keywords' => $this->getKeywords($data), + 'series_ended' => (bool) in_array( + Arr::get($data, 'status'), + self::SERIES_ENDED_STATUS, + ), + 'adult' => Arr::get($data, 'adult', false), + ]; + + if (Arr::get($data, 'seasons')) { + $transformed['seasons'] = $this->getSeasons($data); + } + + return $transformed; + } + + /** + * Get US certification for title. + * + * @param array $data + * @param $type + * @return string|null + */ + private function getCertification($data, $type) + { + if ($type === Title::SERIES_TYPE) { + $firstKey = 'content_ratings.results'; + $secondKey = 'rating'; + } else { + $firstKey = 'release_dates.results'; + $secondKey = 'release_dates.*.certification'; + } + + $rating = collect(Arr::get($data, $firstKey, [])) + ->where('iso_3166_1', 'US') + ->pluck($secondKey) + ->flatten() + ->filter() + ->first(); + + return $rating ? str_replace('tv-', '', strtolower($rating)) : null; + } + + private function getCountries($data) + { + return array_map( + fn($country) => [ + 'name' => strtolower($country['iso_3166_1']), + 'display_name' => $country['name'], + ], + Arr::get($data, 'production_countries', []), + ); + } + + private function transformEpisode(array $data): array + { + $releaseDate = $this->getReleaseDate('air_date', $data); + $epNum = Arr::get($data, 'episode_number'); + $sNum = Arr::get($data, 'season_number'); + + return [ + 'id' => $this->encodeTmdbId( + 'tmdb', + Episode::MODEL_TYPE, + $data['id'], + ), + 'model_type' => Episode::MODEL_TYPE, + 'poster' => $this->getPoster(Arr::get($data, 'still_path')), + 'release_date' => $releaseDate, + 'cast' => $this->getCredits($data), + // episode might not have a name sometimes, auto generate it in that case + 'name' => $this->getTitle($data) ?? "Episode #$sNum.$epNum", + 'description' => $data['overview'], + 'tmdb_vote_count' => $data['vote_count'], + 'tmdb_vote_average' => round($data['vote_average'], 1) ?: null, + 'popularity' => Arr::get($data, 'popularity'), + 'episode_number' => $epNum, + 'season_number' => $sNum, + 'runtime' => $data['runtime'] ?? null, + ]; + } + + private function getPoster(?string $path): ?string + { + return $path ? self::TMDB_IMAGE_BASE . $path : null; + } + + private function getBackdrop($data): ?string + { + $backdrop = Arr::get($data, 'backdrop_path'); + return $backdrop ? self::BACKDROP_BASE_URI . $backdrop : null; + } + + private function getSeasons(array $data): ?array + { + if (!Arr::has($data, 'seasons')) { + return null; + } + + // skip "specials" season with number of "0" + $seasons = array_filter( + Arr::get($data, 'seasons', []), + fn($season) => $season['season_number'] !== 0, + ); + + return array_map( + fn($season) => Arr::except( + $this->transformSeason($season), + 'model_type', + ), + $seasons, + ); + } + + private function transformSeason(array $data): array + { + $releaseDate = $this->getReleaseDate( + ['first_air_date', 'air_date'], + $data, + ); + + $transformedData = [ + 'id' => $this->encodeTmdbId( + 'tmdb', + Season::MODEL_TYPE, + $data['id'], + ), + 'model_type' => Season::MODEL_TYPE, + 'poster' => $this->getPoster(Arr::get($data, 'poster_path')), + 'release_date' => $releaseDate, + 'number' => Arr::get($data, 'season_number'), + ]; + + if (isset($data['credits'])) { + $transformedData['cast'] = $this->getCredits($data); + } + if (isset($data['episodes'])) { + $transformedData['episodes'] = array_map( + fn($episode) => $this->transformEpisode($episode), + $data['episodes'], + ); + } + + return $transformedData; + } + + private function getKeywords(array $data): array + { + $keywords = array_merge( + Arr::get($data, 'keywords.results', []), + Arr::get($data, 'keywords.keywords', []), + ); + + return array_map( + fn($keyword) => ['name' => $keyword['name']], + $keywords, + ); + } + + private function getRuntime(array $data): ?int + { + $runtime = Arr::get( + $data, + 'runtime', + Arr::get($data, 'episode_run_time'), + ); + + if (is_array($runtime)) { + $runtime = !empty($runtime) ? min($runtime) : null; + } + + return $runtime; + } + + private function transformPerson($tmdbPerson): ?array + { + if (!isset($tmdbPerson['id'])) { + return null; + } + + $syncCredits = $this->settings->get('content.automate_filmography'); + $hasCredits = Arr::has($tmdbPerson, 'combined_credits') && $syncCredits; + $hasKnownForCredits = + Arr::has($tmdbPerson, 'known_for') && $syncCredits; + + $data = [ + 'id' => $this->encodeTmdbId( + 'tmdb', + Person::MODEL_TYPE, + $tmdbPerson['id'], + ), + 'name' => $tmdbPerson['name'], + 'tmdb_id' => $tmdbPerson['id'], + 'imdb_id' => Arr::get($tmdbPerson, 'imdb_id'), + 'gender' => $this->transformGender(Arr::get($tmdbPerson, 'gender')), + 'poster' => $this->getPoster($tmdbPerson['profile_path']), + 'model_type' => Person::MODEL_TYPE, + 'adult' => Arr::get($tmdbPerson, 'adult', false), + 'fully_synced' => Arr::get($tmdbPerson, 'fully_synced') ?: false, + 'relation_data' => [ + 'character' => Arr::get($tmdbPerson, 'character') ?: null, + 'order' => Arr::get($tmdbPerson, 'order', 0), + 'department' => strtolower( + Arr::get($tmdbPerson, 'department', 'actors'), + ), + 'job' => strtolower(Arr::get($tmdbPerson, 'job', 'actor')), + ], + ]; + + // "known_for" credits will only be returned from "search" tmdb api call. + if ( + !$hasCredits && + $hasKnownForCredits && + isset($tmdbPerson['known_for'][0]) + ) { + $data['primary_credit'] = $this->transformMediaItem( + $tmdbPerson['known_for'][0], + ); + } + + if ($hasCredits) { + $credits = array_merge( + Arr::get($tmdbPerson, 'combined_credits.cast'), + Arr::get($tmdbPerson, 'combined_credits.crew'), + ); + + $credits = array_map(function ($credit) { + $title = array_filter( + $this->transformMediaItem($credit), + fn($value) => !is_array($value), + ); + + $title['relation_data'] = [ + 'department' => strtolower( + Arr::get($credit, 'department', 'actors'), + ), + 'job' => strtolower(Arr::get($credit, 'job', 'actor')), + 'character' => Arr::get($credit, 'character') ?: null, + 'order' => Arr::get($credit, 'order', 0), + ]; + + return $title; + }, $credits); + + $credits = array_filter( + $credits, + fn($credit) => !Arr::get($credit, 'adult') || + $this->settings->get('tmdb.includeAdult'), + ); + + $data['credits'] = $credits; + } + + $optionalProps = [ + 'biography' => 'description', + 'birthday' => 'birth_date', + 'deathday' => 'death_date', + 'place_of_birth' => 'birth_place', + 'known_for_department' => 'known_for', + 'popularity' => 'popularity', + ]; + + // can't set these as "null" as some data might not be contained + // when getting people via movie/series filmography + foreach ($optionalProps as $tmdbKey => $localKey) { + if (Arr::has($tmdbPerson, $tmdbKey)) { + $data[$localKey] = $tmdbPerson[$tmdbKey]; + } + } + + return $data; + } + + private function getCredits(array $tmdbTitle): array + { + // cast/crew from series, movies and episodes + $credits = array_merge( + Arr::get($tmdbTitle, 'credits.cast', []), + Arr::get($tmdbTitle, 'credits.crew', []), + Arr::get($tmdbTitle, 'crew', []), + Arr::get($tmdbTitle, 'guest_stars', []), + ); + + if ($createdBy = Arr::get($tmdbTitle, 'created_by')) { + $creators = array_map(function ($person) { + $person['job'] = 'creator'; + $person['department'] = 'creators'; + $person['known_for_department'] = 'creators'; + $person['popularity'] = 3; + return $person; + }, $createdBy); + $credits = array_merge($credits, $creators); + } + + $transformedCredits = array_map( + fn($person) => $this->transformPerson($person), + $credits, + ); + + return array_filter($transformedCredits); + } + + /** + * @param int|null $gender + * @return null|string + */ + private function transformGender($gender) + { + if ($gender === 1) { + return 'female'; + } elseif ($gender === 2) { + return 'male'; + } else { + return null; + } + } + + private function transformImages(array $tmdbTitle): array + { + $images = Arr::get($tmdbTitle, 'images.backdrops', []); + + return array_map( + fn($image) => [ + 'type' => 'backdrop', + 'source' => 'tmdb', + 'url' => self::TMDB_IMAGE_BASE . $image['file_path'], + ], + $images, + ); + } + + /** + * @param $tmdbTitle + * @return array + */ + private function getGenres($tmdbTitle) + { + return array_map( + fn($genre) => ['name' => $genre['name']], + Arr::get($tmdbTitle, 'genres', []), + ); + } + + public function formatVideos(array $videos): array + { + $videos = array_map( + fn($video) => [ + 'name' => trim($video['name']), + 'src' => self::YOUTUBE_BASE_URI . $video['key'], + 'type' => Video::VIDEO_TYPE_EMBED, + 'origin' => 'tmdb', + 'category' => strtolower(Arr::get($video, 'type', 'trailer')), + ], + $videos, + ); + + // show trailers first + usort($videos, function ($a, $b) { + if ($a['category'] === 'trailer') { + return -1; + } elseif ($b['category'] === 'trailer') { + return 1; + } else { + return 0; + } + }); + + return $videos; + } + + /** + * @param array $data + * @return string + */ + private function getType($data) + { + $hasSeasonNumber = Arr::get($data, 'season_number'); + $hasEpisodeNumber = Arr::get($data, 'episode_number'); + + if ($hasEpisodeNumber && $hasSeasonNumber) { + return Episode::MODEL_TYPE; + } elseif ($hasSeasonNumber) { + return Season::MODEL_TYPE; + } elseif ( + Arr::get($data, 'media_type') === 'person' || + Arr::has($data, 'gender') + ) { + return Person::MODEL_TYPE; + } elseif (Arr::has($data, 'first_air_date')) { + return Title::SERIES_TYPE; + } else { + return Title::MOVIE_TYPE; + } + } + + private function getReleaseDate(string|array $key, array $data): ?Carbon + { + $potentials = !is_array($key) ? [$key] : $key; + foreach ($potentials as $potential) { + if (isset($data[$potential])) { + return Carbon::parse($data[$potential]); + } + } + return null; + } + + /** + * @param array $tmdbTitle + * @return string|null + */ + private function getTitle($tmdbTitle) + { + if (isset($tmdbTitle['title'])) { + return $tmdbTitle['title']; + } elseif (isset($tmdbTitle['name'])) { + return $tmdbTitle['name']; + } else { + return null; + } + } + + /** + * @param array $tmdbTitle + * @return string|null + */ + private function getOriginalName($tmdbTitle) + { + if (isset($tmdbTitle['original_title'])) { + return $tmdbTitle['original_title']; + } elseif (isset($tmdbTitle['original_name'])) { + return $tmdbTitle['original_name']; + } else { + return null; + } + } +} diff --git a/app/Services/Settings/Validators/TmdbApiKeyValidator.php b/app/Services/Settings/Validators/TmdbApiKeyValidator.php new file mode 100755 index 0000000..93c7287 --- /dev/null +++ b/app/Services/Settings/Validators/TmdbApiKeyValidator.php @@ -0,0 +1,44 @@ + $apiKey]); + } + + try { + app(TmdbApi::class)->browse(); + } catch (ClientException $e) { + $errResponse = json_decode( + $e + ->getResponse() + ->getBody() + ->getContents(), + true, + 512, + JSON_THROW_ON_ERROR, + ); + return $this->getMessage($errResponse); + } + } + + /** + * @param array $errResponse + * @return array + */ + private function getMessage($errResponse) + { + return ['tmdb_api_key' => 'This Themoviedb api key is not valid.']; + } +} diff --git a/app/Services/SitemapGenerator.php b/app/Services/SitemapGenerator.php new file mode 100755 index 0000000..21e595f --- /dev/null +++ b/app/Services/SitemapGenerator.php @@ -0,0 +1,82 @@ +where(function (Builder $query) { + $query->where('fully_synced', true)->orWhereNull('tmdb_id'); + }) + ->whereNotNull('name') + ->select(['id', 'name', 'updated_at']), + app(Person::class) + ->where('fully_synced', true) + ->orWhereNull('tmdb_id') + ->select(['id', 'name', 'updated_at']), + app(Episode::class) + ->whereHas('title') + ->with(['title' => fn($q) => $q->compact()]) + ->select([ + 'id', + 'name', + 'title_id', + 'season_number', + 'episode_number', + 'updated_at', + ]), + app(Season::class) + ->whereHas('title') + ->with(['title' => fn($q) => $q->compact()]) + ->select(['id', 'title_id', 'number']), + Video::select(['id', 'name', 'updated_at']), + Channel::where('public', true) + ->where('internal', false) + ->select(['id', 'name', 'slug', 'updated_at']), + app(NewsArticle::class)->select([ + 'id', + 'title', + 'slug', + 'updated_at', + ]), + ]; + } + + protected function getAppStaticUrls(): array + { + return ['series', 'movies', 'people', 'news']; + } + + protected function addTitleLine( + string $url, + string $updatedAt, + string $name, + ) { + $this->addNewLine($url, $updatedAt, $name); + $this->addNewLine("$url/full-credits", $updatedAt, $name); + } + + protected function getModelUrl(Model $model): string + { + if ($model instanceof Season) { + return app(AppUrlGenerator::class)->season($model, $model->title); + } + if ($model instanceof Episode) { + return app(AppUrlGenerator::class)->episode($model, $model->title); + } + return parent::getModelUrl($model); + } +} diff --git a/app/Services/UrlGenerator.php b/app/Services/UrlGenerator.php new file mode 100755 index 0000000..9cd3ad6 --- /dev/null +++ b/app/Services/UrlGenerator.php @@ -0,0 +1,100 @@ +title($title); + return "$titleUrl/season/{$season['number']}"; + } + + public function episode(array|Episode $episode, $dataOrTitle): string + { + $title = $dataOrTitle['title'] ?? $dataOrTitle; + $titleUrl = $this->title($title); + return "$titleUrl/season/{$episode['season_number']}/episode/{$episode['episode_number']}"; + } + + public function video(array|Video $video): string + { + return $this->watch($video); + } + + public function watch(array|Video $video): string + { + return url("watch/{$video['id']}"); + } + + public function person(array|Person $person): string + { + $slug = slugify($person['name']); + return url("people/{$person['id']}/{$slug}"); + } + + public function article(array|NewsArticle $article): string + { + return url("news/{$article['id']}"); + } + + public function genre(array|Genre $genre): string + { + return url("genre/{$genre['id']}"); + } + + public function search(string $query): string + { + return url("search/{$query}"); + } + + public function user(User|array $model): string + { + return url('users/' . $model['id']); + } + + public function channel(Channel|ChannelResource|array $model): string + { + if ($model['type'] === 'list') { + return url("lists/{$model['id']}"); + } elseif ( + settings('homepage.type') === 'channels' && + settings('homepage.value') === $model['id'] + ) { + return url('/'); + } else { + $url = url($model['slug'] ?: slugify($model['name'])); + if (isset($model['restriction'])) { + return "$url/{$model['restriction']['name']}"; + } + return $url; + } + } + + public function image(string|null $path): string|null + { + if ($path && !str_starts_with($path, 'http')) { + return url($path); + } + return $path; + } +} diff --git a/artisan b/artisan new file mode 100755 index 0000000..44dc07b --- /dev/null +++ b/artisan @@ -0,0 +1,51 @@ +#!/usr/bin/env php +make(Illuminate\Contracts\Console\Kernel::class); + +$status = $kernel->handle( + $input = new Symfony\Component\Console\Input\ArgvInput, + new Symfony\Component\Console\Output\ConsoleOutput +); + +/* +|-------------------------------------------------------------------------- +| Shutdown The Application +|-------------------------------------------------------------------------- +| +| Once Artisan has finished running, we will fire off the shutdown events +| so that any final work may be done by the application before we shut +| down the process. This is the last thing to happen to the request. +| +*/ + +$kernel->terminate($input, $status); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100755 index 0000000..f2801ad --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,55 @@ +singleton( + Illuminate\Contracts\Http\Kernel::class, + App\Http\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Console\Kernel::class, + App\Console\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Debug\ExceptionHandler::class, + App\Exceptions\Handler::class +); + +/* +|-------------------------------------------------------------------------- +| Return The Application +|-------------------------------------------------------------------------- +| +| This script returns the application instance. The instance is given to +| the calling script so we can separate the building of the instances +| from the actual running of the application and sending responses. +| +*/ + +return $app; diff --git a/bootstrap/autoload.php b/bootstrap/autoload.php new file mode 100755 index 0000000..cc73633 --- /dev/null +++ b/bootstrap/autoload.php @@ -0,0 +1,15 @@ +\n, 'LockOpenOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const ShareIcon = createSvgIcon(\n \n, 'ShareOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const ExpandMoreIcon = createSvgIcon(\n \n, 'ExpandMoreOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const CommentIcon = createSvgIcon(\n \n, 'CommentOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const ThumbUpIcon = createSvgIcon(\n \n, 'ThumbUpOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const ThumbDownIcon = createSvgIcon(\n \n, 'ThumbDownOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const ReplyIcon = createSvgIcon(\n \n, 'ReplyOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const FlagIcon = createSvgIcon(\n \n, 'OutlinedFlagOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const ListAltIcon = createSvgIcon(\n \n, 'ListAltOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const RateReviewIcon = createSvgIcon(\n \n, 'RateReviewOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const BookmarkBorderIcon = createSvgIcon(\n \n, 'BookmarkBorderOutlined');\n"],"names":[],"mappings":";;AAEO,MAAM,eAAe;AAAA,EAC1B,oBAAC,QAAK,EAAA,GAAE,qNAAqN,CAAA;AAAA,EAC7N;AAAkB;ACFb,MAAM,YAAY;AAAA,EACvB,oBAAC,QAAK,EAAA,GAAE,2gBAA2gB,CAAA;AAAA,EACnhB;AAAe;ACFV,MAAM,iBAAiB;AAAA,EAC5B,oBAAC,QAAK,EAAA,GAAE,yDAAyD,CAAA;AAAA,EACjE;AAAoB;ACFf,MAAM,cAAc;AAAA,EACzB,oBAAC,QAAK,EAAA,GAAE,iJAAiJ,CAAA;AAAA,EACzJ;AAAiB;ACFZ,MAAM,cAAc;AAAA,EACzB,oBAAC,QAAK,EAAA,GAAE,wOAAwO,CAAA;AAAA,EAChP;AAAiB;ACFZ,MAAM,gBAAgB;AAAA,EAC3B,oBAAC,QAAK,EAAA,GAAE,0OAA0O,CAAA;AAAA,EAClP;AAAmB;ACFd,MAAM,YAAY;AAAA,EACvB,oBAAC,QAAK,EAAA,GAAE,2DAA2D,CAAA;AAAA,EACnE;AAAe;ACFV,MAAM,WAAW;AAAA,EACtB,oBAAC,QAAK,EAAA,GAAE,+DAA+D,CAAA;AAAA,EACvE;AAAsB;ACFjB,MAAM,cAAc;AAAA,EACzB,oBAAC,QAAK,EAAA,GAAE,kLAAkL,CAAA;AAAA,EAC1L;AAAiB;ACFZ,MAAM,iBAAiB;AAAA,EAC5B,oBAAC,QAAK,EAAA,GAAE,uNAAuN,CAAA;AAAA,EAC/N;AAAoB;ACFf,MAAM,qBAAqB;AAAA,EAChC,oBAAC,QAAK,EAAA,GAAE,iFAAiF,CAAA;AAAA,EACzF;AAAwB;"} \ No newline at end of file diff --git a/bootstrap/ssr/assets/CalendarToday-257ff5df.mjs b/bootstrap/ssr/assets/CalendarToday-257ff5df.mjs new file mode 100755 index 0000000..263ccab --- /dev/null +++ b/bootstrap/ssr/assets/CalendarToday-257ff5df.mjs @@ -0,0 +1,10 @@ +import { jsx } from "react/jsx-runtime"; +import { c as createSvgIcon } from "../server-entry.mjs"; +const CalendarTodayIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V10h16v11zm0-13H4V5h16v3z" }), + "CalendarTodayOutlined" +); +export { + CalendarTodayIcon as C +}; +//# sourceMappingURL=CalendarToday-257ff5df.mjs.map diff --git a/bootstrap/ssr/assets/CalendarToday-257ff5df.mjs.map b/bootstrap/ssr/assets/CalendarToday-257ff5df.mjs.map new file mode 100755 index 0000000..443677e --- /dev/null +++ b/bootstrap/ssr/assets/CalendarToday-257ff5df.mjs.map @@ -0,0 +1 @@ +{"version":3,"file":"CalendarToday-257ff5df.mjs","sources":["../../../common/resources/client/icons/material/CalendarToday.tsx"],"sourcesContent":["import {createSvgIcon} from '../create-svg-icon';\n\nexport const CalendarTodayIcon = createSvgIcon(\n \n, 'CalendarTodayOutlined');\n"],"names":[],"mappings":";;AAEO,MAAM,oBAAoB;AAAA,EAC/B,oBAAC,QAAK,EAAA,GAAE,iIAAiI,CAAA;AAAA,EACzI;AAAuB;"} \ No newline at end of file diff --git a/bootstrap/ssr/assets/OpenInNew-3b47a656.mjs b/bootstrap/ssr/assets/OpenInNew-3b47a656.mjs new file mode 100755 index 0000000..c409dc9 --- /dev/null +++ b/bootstrap/ssr/assets/OpenInNew-3b47a656.mjs @@ -0,0 +1,238 @@ +import { jsx, jsxs } from "react/jsx-runtime"; +import React, { useRef, useCallback, cloneElement } from "react"; +import { useValueEffect, useResizeObserver, useLayoutEffect } from "@react-aria/utils"; +import clsx from "clsx"; +import { c as createSvgIcon, i as useTrans, ah as MenuTrigger, I as IconButton, ai as Menu, h as Item } from "../server-entry.mjs"; +const ChevronRightIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z" }), + "ChevronRightOutlined" +); +function BreadcrumbItem(props) { + const { + isCurrent, + sizeStyle: sizeStyle2, + isMenuTrigger, + isClickable, + isDisabled, + onSelected, + className, + isMenuItem, + isLink + } = props; + const children = typeof props.children === "function" ? props.children({ isMenuItem }) : props.children; + if (isMenuItem) { + return children; + } + const domProps = isMenuTrigger ? {} : { + tabIndex: isLink && !isDisabled ? 0 : void 0, + role: isLink ? "link" : void 0, + "aria-disabled": isLink ? isDisabled : void 0, + "aria-current": isCurrent && isLink ? "page" : void 0, + onClick: () => onSelected == null ? void 0 : onSelected() + }; + return /* @__PURE__ */ jsxs( + "li", + { + className: clsx( + `relative inline-flex min-w-0 flex-shrink-0 items-center justify-start ${sizeStyle2 == null ? void 0 : sizeStyle2.font}`, + (!isClickable || isDisabled) && "pointer-events-none", + !isCurrent && isDisabled && "text-disabled" + ), + children: [ + /* @__PURE__ */ jsx( + "div", + { + ...domProps, + className: clsx( + className, + "cursor-pointer overflow-hidden whitespace-nowrap rounded px-8", + !isMenuTrigger && "py-4 hover:bg-hover", + !isMenuTrigger && isLink && "outline-none focus-visible:ring" + ), + children + } + ), + isCurrent === false && /* @__PURE__ */ jsx( + ChevronRightIcon, + { + size: sizeStyle2 == null ? void 0 : sizeStyle2.icon, + className: clsx(isDisabled ? "text-disabled" : "text-muted") + } + ) + ] + } + ); +} +const MoreHorizIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" }), + "MoreHorizOutlined" +); +const MIN_VISIBLE_ITEMS = 1; +const MAX_VISIBLE_ITEMS = 10; +function Breadcrumb(props) { + const { + size = "md", + children, + isDisabled, + className, + currentIsClickable, + isNavigation + } = props; + const { trans } = useTrans(); + const style = sizeStyle(size); + const childArray = []; + React.Children.forEach(children, (child) => { + if (React.isValidElement(child)) { + childArray.push(child); + } + }); + const domRef = useRef(null); + const listRef = useRef(null); + const [visibleItems, setVisibleItems] = useValueEffect(childArray.length); + const updateOverflow = useCallback(() => { + const computeVisibleItems = (itemCount) => { + var _a; + const currListRef = listRef.current; + if (!currListRef) { + return; + } + const listItems = Array.from(currListRef.children); + if (!listItems.length) + return; + const containerWidth = currListRef.offsetWidth; + const isShowingMenu = childArray.length > itemCount; + let calculatedWidth = 0; + let newVisibleItems = 0; + let maxVisibleItems = MAX_VISIBLE_ITEMS; + calculatedWidth += listItems.shift().offsetWidth; + newVisibleItems++; + if (isShowingMenu) { + calculatedWidth += ((_a = listItems.shift()) == null ? void 0 : _a.offsetWidth) ?? 0; + maxVisibleItems--; + } + if (calculatedWidth >= containerWidth) { + newVisibleItems--; + } + if (listItems.length > 0) { + const last = listItems.pop(); + last.style.overflow = "visible"; + calculatedWidth += last.offsetWidth; + if (calculatedWidth < containerWidth) { + newVisibleItems++; + } + last.style.overflow = ""; + } + for (const breadcrumb of listItems.reverse()) { + calculatedWidth += breadcrumb.offsetWidth; + if (calculatedWidth < containerWidth) { + newVisibleItems++; + } + } + return Math.max( + MIN_VISIBLE_ITEMS, + Math.min(maxVisibleItems, newVisibleItems) + ); + }; + setVisibleItems(function* () { + yield childArray.length; + const newVisibleItems = computeVisibleItems(childArray.length); + yield newVisibleItems; + if (newVisibleItems < childArray.length && newVisibleItems > 1) { + yield computeVisibleItems(newVisibleItems); + } + }); + }, [listRef, children, setVisibleItems]); + useResizeObserver({ ref: domRef, onResize: updateOverflow }); + useLayoutEffect(updateOverflow, [children]); + let contents = childArray; + if (childArray.length > visibleItems) { + const selectedKey = childArray.length - 1; + const menuItem = /* @__PURE__ */ jsx(BreadcrumbItem, { sizeStyle: style, isMenuTrigger: true, children: /* @__PURE__ */ jsxs(MenuTrigger, { selectionMode: "single", selectedValue: selectedKey, children: [ + /* @__PURE__ */ jsx(IconButton, { "aria-label": "…", disabled: isDisabled, size: style.btn, children: /* @__PURE__ */ jsx(MoreHorizIcon, {}) }), + /* @__PURE__ */ jsx(Menu, { children: childArray.map((child, index) => { + const isLast = selectedKey === index; + return /* @__PURE__ */ jsx( + Item, + { + value: index, + onSelected: () => { + var _a, _b; + if (!isLast) { + (_b = (_a = child.props).onSelected) == null ? void 0 : _b.call(_a); + } + }, + children: cloneElement(child, { isMenuItem: true }) + }, + index + ); + }) }) + ] }) }, "menu"); + contents = [menuItem]; + const breadcrumbs = [...childArray]; + let endItems = visibleItems; + if (visibleItems > 1) { + contents.unshift(breadcrumbs.shift()); + endItems--; + } + contents.push(...breadcrumbs.slice(-endItems)); + } + const lastIndex = contents.length - 1; + const breadcrumbItems = contents.map((child, index) => { + const isCurrent = index === lastIndex; + const isClickable = !isCurrent || currentIsClickable; + return cloneElement(child, { + key: child.key || index, + isCurrent, + sizeStyle: style, + isClickable, + isDisabled, + isLink: isNavigation && child.key !== "menu" + }); + }); + const Element = isNavigation ? "nav" : "div"; + return /* @__PURE__ */ jsx( + Element, + { + className: clsx(className, "w-full min-w-0"), + "aria-label": trans({ message: "Breadcrumbs" }), + ref: domRef, + children: /* @__PURE__ */ jsx( + "ol", + { + ref: listRef, + className: clsx("flex flex-nowrap justify-start", style.minHeight), + children: breadcrumbItems + } + ) + } + ); +} +function sizeStyle(size) { + switch (size) { + case "sm": + return { font: "text-sm", icon: "sm", btn: "sm", minHeight: "min-h-36" }; + case "lg": + return { font: "text-lg", icon: "md", btn: "md", minHeight: "min-h-42" }; + case "xl": + return { font: "text-xl", icon: "md", btn: "md", minHeight: "min-h-42" }; + default: + return { font: "text-base", icon: "md", btn: "md", minHeight: "min-h-42" }; + } +} +const EditIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z" }), + "EditOutlined" +); +const OpenInNewIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z" }), + "OpenInNewOutlined" +); +export { + Breadcrumb as B, + ChevronRightIcon as C, + EditIcon as E, + MoreHorizIcon as M, + OpenInNewIcon as O, + BreadcrumbItem as a +}; +//# sourceMappingURL=OpenInNew-3b47a656.mjs.map diff --git a/bootstrap/ssr/assets/OpenInNew-3b47a656.mjs.map b/bootstrap/ssr/assets/OpenInNew-3b47a656.mjs.map new file mode 100755 index 0000000..16c7dfb --- /dev/null +++ b/bootstrap/ssr/assets/OpenInNew-3b47a656.mjs.map @@ -0,0 +1 @@ +{"version":3,"file":"OpenInNew-3b47a656.mjs","sources":["../../../common/resources/client/icons/material/ChevronRight.tsx","../../../common/resources/client/ui/breadcrumbs/breadcrumb-item.tsx","../../../common/resources/client/icons/material/MoreHoriz.tsx","../../../common/resources/client/ui/breadcrumbs/breadcrumb.tsx","../../../common/resources/client/icons/material/Edit.tsx","../../../common/resources/client/icons/material/OpenInNew.tsx"],"sourcesContent":["import {createSvgIcon} from '../create-svg-icon';\n\nexport const ChevronRightIcon = createSvgIcon(\n \n, 'ChevronRightOutlined');\n","import React, {HTMLAttributes, ReactElement, ReactNode} from 'react';\nimport clsx from 'clsx';\nimport {ChevronRightIcon} from '../../icons/material/ChevronRight';\nimport type {BreadcrumbSizeStyle} from './breadcrumb';\n\nexport interface BreadcrumbItemProps {\n sizeStyle?: BreadcrumbSizeStyle;\n isMenuTrigger?: boolean;\n isMenuItem?: boolean;\n children: ReactNode | ((state: {isMenuItem?: boolean}) => ReactNode);\n isCurrent?: boolean;\n onSelected?: () => void;\n isClickable?: boolean;\n isDisabled?: boolean;\n className?: string;\n isLink?: boolean;\n}\n\nexport function BreadcrumbItem(props: BreadcrumbItemProps) {\n const {\n isCurrent,\n sizeStyle,\n isMenuTrigger,\n isClickable,\n isDisabled,\n onSelected,\n className,\n isMenuItem,\n isLink,\n } = props;\n\n const children =\n typeof props.children === 'function'\n ? props.children({isMenuItem})\n : props.children;\n\n if (isMenuItem) {\n return children as ReactElement;\n }\n\n const domProps: HTMLAttributes = isMenuTrigger\n ? {}\n : {\n tabIndex: isLink && !isDisabled ? 0 : undefined,\n role: isLink ? 'link' : undefined,\n 'aria-disabled': isLink ? isDisabled : undefined,\n 'aria-current': isCurrent && isLink ? 'page' : undefined,\n onClick: () => onSelected?.(),\n };\n\n return (\n \n \n {children}\n
\n {isCurrent === false && (\n \n )}\n \n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const MoreHorizIcon = createSvgIcon(\n \n, 'MoreHorizOutlined');\n","import React, {\n cloneElement,\n ReactElement,\n ReactNode,\n useCallback,\n useRef,\n} from 'react';\nimport {\n useLayoutEffect,\n useResizeObserver,\n useValueEffect,\n} from '@react-aria/utils';\nimport clsx from 'clsx';\nimport {IconButton} from '../buttons/icon-button';\nimport {BreadcrumbItem, BreadcrumbItemProps} from './breadcrumb-item';\nimport {MoreHorizIcon} from '../../icons/material/MoreHoriz';\nimport {ButtonSize} from '../buttons/button-size';\nimport {Menu, MenuItem, MenuTrigger} from '../navigation/menu/menu-trigger';\nimport {IconSize} from '../../icons/svg-icon';\nimport {useTrans} from '../../i18n/use-trans';\n\nconst MIN_VISIBLE_ITEMS = 1;\nconst MAX_VISIBLE_ITEMS = 10;\n\nexport interface BreadcrumbsProps {\n children?: ReactNode;\n isDisabled?: boolean;\n size?: 'sm' | 'md' | 'lg' | 'xl';\n className?: string;\n currentIsClickable?: boolean;\n isNavigation?: boolean;\n}\n\nexport function Breadcrumb(props: BreadcrumbsProps) {\n const {\n size = 'md',\n children,\n isDisabled,\n className,\n currentIsClickable,\n isNavigation,\n } = props;\n const {trans} = useTrans();\n const style = sizeStyle(size);\n\n // Not using React.Children.toArray because it mutates the key prop.\n const childArray: ReactElement[] = [];\n React.Children.forEach(children, child => {\n if (React.isValidElement(child)) {\n childArray.push(child as ReactElement);\n }\n });\n\n const domRef = useRef(null);\n const listRef = useRef(null);\n\n const [visibleItems, setVisibleItems] = useValueEffect(childArray.length);\n\n const updateOverflow = useCallback(() => {\n const computeVisibleItems = (itemCount: number) => {\n // Refs can be null at runtime.\n const currListRef: HTMLUListElement | null = listRef.current;\n if (!currListRef) {\n return;\n }\n\n const listItems = Array.from(currListRef.children) as HTMLLIElement[];\n if (!listItems.length) return;\n\n const containerWidth = currListRef.offsetWidth;\n const isShowingMenu = childArray.length > itemCount;\n let calculatedWidth = 0;\n let newVisibleItems = 0;\n let maxVisibleItems = MAX_VISIBLE_ITEMS;\n\n calculatedWidth += listItems.shift()!.offsetWidth;\n newVisibleItems++;\n\n if (isShowingMenu) {\n calculatedWidth += listItems.shift()?.offsetWidth ?? 0;\n maxVisibleItems--;\n }\n\n if (calculatedWidth >= containerWidth) {\n newVisibleItems--;\n }\n\n // Ensure the last breadcrumb isn't truncated when we measure it.\n if (listItems.length > 0) {\n const last = listItems.pop();\n last!.style.overflow = 'visible';\n\n calculatedWidth += last!.offsetWidth;\n if (calculatedWidth < containerWidth) {\n newVisibleItems++;\n }\n\n last!.style.overflow = '';\n }\n\n // eslint-disable-next-line no-restricted-syntax\n for (const breadcrumb of listItems.reverse()) {\n calculatedWidth += breadcrumb.offsetWidth;\n if (calculatedWidth < containerWidth) {\n newVisibleItems++;\n }\n }\n\n return Math.max(\n MIN_VISIBLE_ITEMS,\n Math.min(maxVisibleItems, newVisibleItems),\n );\n };\n\n // eslint-disable-next-line func-names\n setVisibleItems(function* () {\n // Update to show all items.\n yield childArray.length;\n\n // Measure, and update to show the items that fit.\n const newVisibleItems = computeVisibleItems(childArray.length);\n yield newVisibleItems;\n\n // If the number of items is less than the number of children,\n // then update again to ensure that the menu fits.\n if (newVisibleItems! < childArray.length && newVisibleItems! > 1) {\n yield computeVisibleItems(newVisibleItems!);\n }\n });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [listRef, children, setVisibleItems]);\n\n useResizeObserver({ref: domRef, onResize: updateOverflow});\n\n // eslint-disable-next-line react-hooks/exhaustive-deps\n useLayoutEffect(updateOverflow, [children]);\n\n let contents = childArray;\n if (childArray.length > visibleItems) {\n const selectedKey = childArray.length - 1;\n\n const menuItem = (\n \n \n \n \n \n \n {childArray.map((child, index) => {\n const isLast = selectedKey === index;\n return (\n {\n if (!isLast) {\n child.props.onSelected?.();\n }\n }}\n >\n {cloneElement(child, {isMenuItem: true})}\n \n );\n })}\n \n \n \n );\n\n contents = [menuItem];\n const breadcrumbs = [...childArray];\n let endItems = visibleItems;\n if (visibleItems > 1) {\n contents.unshift(breadcrumbs.shift()!);\n endItems--;\n }\n contents.push(...breadcrumbs.slice(-endItems));\n }\n\n const lastIndex = contents.length - 1;\n const breadcrumbItems = contents.map((child, index) => {\n const isCurrent = index === lastIndex;\n const isClickable = !isCurrent || currentIsClickable;\n\n return cloneElement(child, {\n key: child.key || index,\n isCurrent,\n sizeStyle: style,\n isClickable,\n isDisabled,\n isLink: isNavigation && child.key !== 'menu',\n });\n });\n\n const Element = isNavigation ? 'nav' : 'div';\n\n return (\n \n \n {breadcrumbItems}\n \n \n );\n}\n\nfunction sizeStyle(size: BreadcrumbsProps['size']): BreadcrumbSizeStyle {\n switch (size) {\n case 'sm':\n return {font: 'text-sm', icon: 'sm', btn: 'sm', minHeight: 'min-h-36'};\n case 'lg':\n return {font: 'text-lg', icon: 'md', btn: 'md', minHeight: 'min-h-42'};\n case 'xl':\n return {font: 'text-xl', icon: 'md', btn: 'md', minHeight: 'min-h-42'};\n default:\n return {font: 'text-base', icon: 'md', btn: 'md', minHeight: 'min-h-42'};\n }\n}\n\nexport interface BreadcrumbSizeStyle {\n font: string;\n icon: IconSize;\n btn: ButtonSize;\n minHeight: string;\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const EditIcon = createSvgIcon(\n \n, 'EditOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const OpenInNewIcon = createSvgIcon(\n \n, 'OpenInNewOutlined');\n"],"names":["sizeStyle","MenuItem"],"mappings":";;;;;AAEO,MAAM,mBAAmB;AAAA,EAC9B,oBAAC,QAAK,EAAA,GAAE,qDAAqD,CAAA;AAAA,EAC7D;AAAsB;ACcjB,SAAS,eAAe,OAA4B;AACnD,QAAA;AAAA,IACJ;AAAA,IACA,WAAAA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACE,IAAA;AAEE,QAAA,WACJ,OAAO,MAAM,aAAa,aACtB,MAAM,SAAS,EAAC,WAAA,CAAW,IAC3B,MAAM;AAEZ,MAAI,YAAY;AACP,WAAA;AAAA,EACT;AAEM,QAAA,WAA2C,gBAC7C,KACA;AAAA,IACE,UAAU,UAAU,CAAC,aAAa,IAAI;AAAA,IACtC,MAAM,SAAS,SAAS;AAAA,IACxB,iBAAiB,SAAS,aAAa;AAAA,IACvC,gBAAgB,aAAa,SAAS,SAAS;AAAA,IAC/C,SAAS,MAAM;AAAA,EAAa;AAIhC,SAAA;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,WAAW;AAAA,QACT,yEAAyEA,cAAA,gBAAAA,WAAW,IAAI;AAAA,SACvF,CAAC,eAAe,eAAe;AAAA,QAChC,CAAC,aAAa,cAAc;AAAA,MAC9B;AAAA,MAEA,UAAA;AAAA,QAAA;AAAA,UAAC;AAAA,UAAA;AAAA,YACE,GAAG;AAAA,YACJ,WAAW;AAAA,cACT;AAAA,cACA;AAAA,cACA,CAAC,iBAAiB;AAAA,cAClB,CAAC,iBAAiB,UAAU;AAAA,YAC9B;AAAA,YAEC;AAAA,UAAA;AAAA,QACH;AAAA,QACC,cAAc,SACb;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,MAAMA,cAAA,gBAAAA,WAAW;AAAA,YACjB,WAAW,KAAK,aAAa,kBAAkB,YAAY;AAAA,UAAA;AAAA,QAC7D;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAIR;AC3EO,MAAM,gBAAgB;AAAA,EAC3B,oBAAC,QAAK,EAAA,GAAE,sJAAsJ,CAAA;AAAA,EAC9J;AAAmB;ACiBrB,MAAM,oBAAoB;AAC1B,MAAM,oBAAoB;AAWnB,SAAS,WAAW,OAAyB;AAC5C,QAAA;AAAA,IACJ,OAAO;AAAA,IACP;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACE,IAAA;AACE,QAAA,EAAC,UAAS;AACV,QAAA,QAAQ,UAAU,IAAI;AAG5B,QAAM,aAAkD,CAAA;AAClD,QAAA,SAAS,QAAQ,UAAU,CAAS,UAAA;AACpC,QAAA,MAAM,eAAe,KAAK,GAAG;AAC/B,iBAAW,KAAK,KAA0C;AAAA,IAC5D;AAAA,EAAA,CACD;AAEK,QAAA,SAAS,OAAuB,IAAI;AACpC,QAAA,UAAU,OAAyB,IAAI;AAE7C,QAAM,CAAC,cAAc,eAAe,IAAI,eAAe,WAAW,MAAM;AAElE,QAAA,iBAAiB,YAAY,MAAM;AACjC,UAAA,sBAAsB,CAAC,cAAsB;;AAEjD,YAAM,cAAuC,QAAQ;AACrD,UAAI,CAAC,aAAa;AAChB;AAAA,MACF;AAEA,YAAM,YAAY,MAAM,KAAK,YAAY,QAAQ;AACjD,UAAI,CAAC,UAAU;AAAQ;AAEvB,YAAM,iBAAiB,YAAY;AAC7B,YAAA,gBAAgB,WAAW,SAAS;AAC1C,UAAI,kBAAkB;AACtB,UAAI,kBAAkB;AACtB,UAAI,kBAAkB;AAEH,yBAAA,UAAU,MAAS,EAAA;AACtC;AAEA,UAAI,eAAe;AACE,6BAAA,eAAU,YAAV,mBAAmB,gBAAe;AACrD;AAAA,MACF;AAEA,UAAI,mBAAmB,gBAAgB;AACrC;AAAA,MACF;AAGI,UAAA,UAAU,SAAS,GAAG;AAClB,cAAA,OAAO,UAAU;AACvB,aAAM,MAAM,WAAW;AAEvB,2BAAmB,KAAM;AACzB,YAAI,kBAAkB,gBAAgB;AACpC;AAAA,QACF;AAEA,aAAM,MAAM,WAAW;AAAA,MACzB;AAGW,iBAAA,cAAc,UAAU,WAAW;AAC5C,2BAAmB,WAAW;AAC9B,YAAI,kBAAkB,gBAAgB;AACpC;AAAA,QACF;AAAA,MACF;AAEA,aAAO,KAAK;AAAA,QACV;AAAA,QACA,KAAK,IAAI,iBAAiB,eAAe;AAAA,MAAA;AAAA,IAC3C;AAIF,oBAAgB,aAAa;AAE3B,YAAM,WAAW;AAGX,YAAA,kBAAkB,oBAAoB,WAAW,MAAM;AACvD,YAAA;AAIN,UAAI,kBAAmB,WAAW,UAAU,kBAAmB,GAAG;AAChE,cAAM,oBAAoB,eAAgB;AAAA,MAC5C;AAAA,IAAA,CACD;AAAA,EAEA,GAAA,CAAC,SAAS,UAAU,eAAe,CAAC;AAEvC,oBAAkB,EAAC,KAAK,QAAQ,UAAU,eAAe,CAAA;AAGzC,kBAAA,gBAAgB,CAAC,QAAQ,CAAC;AAE1C,MAAI,WAAW;AACX,MAAA,WAAW,SAAS,cAAc;AAC9B,UAAA,cAAc,WAAW,SAAS;AAExC,UAAM,WACJ,oBAAC,gBAA0B,EAAA,WAAW,OAAO,eAAa,MACxD,UAAA,qBAAC,aAAY,EAAA,eAAc,UAAS,eAAe,aACjD,UAAA;AAAA,MAAC,oBAAA,YAAA,EAAW,cAAW,KAAI,UAAU,YAAY,MAAM,MAAM,KAC3D,UAAC,oBAAA,eAAA,CAAA,CAAc,EACjB,CAAA;AAAA,0BACC,MACE,EAAA,UAAA,WAAW,IAAI,CAAC,OAAO,UAAU;AAChC,cAAM,SAAS,gBAAgB;AAE7B,eAAA;AAAA,UAACC;AAAAA,UAAA;AAAA,YAEC,OAAO;AAAA,YACP,YAAY,MAAM;;AAChB,kBAAI,CAAC,QAAQ;AACX,kCAAM,OAAM,eAAZ;AAAA,cACF;AAAA,YACF;AAAA,YAEC,UAAa,aAAA,OAAO,EAAC,YAAY,MAAK;AAAA,UAAA;AAAA,UARlC;AAAA,QAAA;AAAA,MAWV,CAAA,GACH;AAAA,IAAA,GACF,KAvBkB,MAwBpB;AAGF,eAAW,CAAC,QAAQ;AACd,UAAA,cAAc,CAAC,GAAG,UAAU;AAClC,QAAI,WAAW;AACf,QAAI,eAAe,GAAG;AACX,eAAA,QAAQ,YAAY,MAAQ,CAAA;AACrC;AAAA,IACF;AACA,aAAS,KAAK,GAAG,YAAY,MAAM,CAAC,QAAQ,CAAC;AAAA,EAC/C;AAEM,QAAA,YAAY,SAAS,SAAS;AACpC,QAAM,kBAAkB,SAAS,IAAI,CAAC,OAAO,UAAU;AACrD,UAAM,YAAY,UAAU;AACtB,UAAA,cAAc,CAAC,aAAa;AAElC,WAAO,aAAkC,OAAO;AAAA,MAC9C,KAAK,MAAM,OAAO;AAAA,MAClB;AAAA,MACA,WAAW;AAAA,MACX;AAAA,MACA;AAAA,MACA,QAAQ,gBAAgB,MAAM,QAAQ;AAAA,IAAA,CACvC;AAAA,EAAA,CACF;AAEK,QAAA,UAAU,eAAe,QAAQ;AAGrC,SAAA;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,WAAW,KAAK,WAAW,gBAAgB;AAAA,MAC3C,cAAY,MAAM,EAAC,SAAS,eAAc;AAAA,MAC1C,KAAK;AAAA,MAEL,UAAA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,KAAK;AAAA,UACL,WAAW,KAAK,kCAAkC,MAAM,SAAS;AAAA,UAEhE,UAAA;AAAA,QAAA;AAAA,MACH;AAAA,IAAA;AAAA,EAAA;AAGN;AAEA,SAAS,UAAU,MAAqD;AACtE,UAAQ,MAAM;AAAA,IACZ,KAAK;AACI,aAAA,EAAC,MAAM,WAAW,MAAM,MAAM,KAAK,MAAM,WAAW;IAC7D,KAAK;AACI,aAAA,EAAC,MAAM,WAAW,MAAM,MAAM,KAAK,MAAM,WAAW;IAC7D,KAAK;AACI,aAAA,EAAC,MAAM,WAAW,MAAM,MAAM,KAAK,MAAM,WAAW;IAC7D;AACS,aAAA,EAAC,MAAM,aAAa,MAAM,MAAM,KAAK,MAAM,WAAW;EACjE;AACF;AC7NO,MAAM,WAAW;AAAA,EACtB,oBAAC,QAAK,EAAA,GAAE,kNAAkN,CAAA;AAAA,EAC1N;AAAc;ACFT,MAAM,gBAAgB;AAAA,EAC3B,oBAAC,QAAK,EAAA,GAAE,qIAAqI,CAAA;AAAA,EAC7I;AAAmB;"} \ No newline at end of file diff --git a/bootstrap/ssr/assets/TaskAlt-798b1c02.mjs b/bootstrap/ssr/assets/TaskAlt-798b1c02.mjs new file mode 100755 index 0000000..116b7ca --- /dev/null +++ b/bootstrap/ssr/assets/TaskAlt-798b1c02.mjs @@ -0,0 +1,10 @@ +import { jsx } from "react/jsx-runtime"; +import { c as createSvgIcon } from "../server-entry.mjs"; +const TaskAltIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M22 5.18 10.59 16.6l-4.24-4.24 1.41-1.41 2.83 2.83 10-10L22 5.18zm-2.21 5.04c.13.57.21 1.17.21 1.78 0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8c1.58 0 3.04.46 4.28 1.25l1.44-1.44C16.1 2.67 14.13 2 12 2 6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10c0-1.19-.22-2.33-.6-3.39l-1.61 1.61z" }), + "TaskAltOutlined" +); +export { + TaskAltIcon as T +}; +//# sourceMappingURL=TaskAlt-798b1c02.mjs.map diff --git a/bootstrap/ssr/assets/TaskAlt-798b1c02.mjs.map b/bootstrap/ssr/assets/TaskAlt-798b1c02.mjs.map new file mode 100755 index 0000000..7256716 --- /dev/null +++ b/bootstrap/ssr/assets/TaskAlt-798b1c02.mjs.map @@ -0,0 +1 @@ +{"version":3,"file":"TaskAlt-798b1c02.mjs","sources":["../../../common/resources/client/icons/material/TaskAlt.tsx"],"sourcesContent":["import {createSvgIcon} from '../create-svg-icon';\n\nexport const TaskAltIcon = createSvgIcon(\n \n, 'TaskAltOutlined');\n"],"names":[],"mappings":";;AAEO,MAAM,cAAc;AAAA,EACzB,oBAAC,QAAK,EAAA,GAAE,4RAA4R,CAAA;AAAA,EACpS;AAAiB;"} \ No newline at end of file diff --git a/bootstrap/ssr/assets/ace-editor-481d400c.mjs b/bootstrap/ssr/assets/ace-editor-481d400c.mjs new file mode 100755 index 0000000..5c1cce5 --- /dev/null +++ b/bootstrap/ssr/assets/ace-editor-481d400c.mjs @@ -0,0 +1,97 @@ +import { jsx } from "react/jsx-runtime"; +import ace from "ace-builds/src-noconflict/ace.js"; +import { useRef, useEffect } from "react"; +import AceEditorRender from "react-ace"; +import "ace-builds/src-noconflict/mode-css.js"; +import "ace-builds/src-noconflict/mode-html.js"; +import "ace-builds/src-noconflict/mode-javascript.js"; +import "ace-builds/src-noconflict/mode-php_laravel_blade.js"; +import "ace-builds/src-noconflict/theme-chrome.js"; +import "ace-builds/src-noconflict/theme-tomorrow_night.js"; +import "ace-builds/src-noconflict/ext-language_tools.js"; +import Beautify from "ace-builds/src-noconflict/ext-beautify.js"; +import { bc as useIsDarkMode } from "../server-entry.mjs"; +import "react-dom/server"; +import "process"; +import "http"; +import "@tanstack/react-query"; +import "axios"; +import "react-router-dom/server.mjs"; +import "framer-motion"; +import "slugify"; +import "deepmerge"; +import "clsx"; +import "@internationalized/date"; +import "nano-memoize"; +import "zustand"; +import "zustand/middleware/immer"; +import "nanoid"; +import "@react-aria/utils"; +import "@react-aria/focus"; +import "@floating-ui/react-dom"; +import "react-merge-refs"; +import "react-dom"; +import "react-router-dom"; +import "@internationalized/number"; +import "react-hook-form"; +import "dot-object"; +import "@react-stately/utils"; +import "@react-aria/ssr"; +import "immer"; +import "axios-retry"; +import "tus-js-client"; +import "react-use-cookie"; +import "mime-match"; +import "react-use-clipboard"; +const cssWorkerUrl = "/assets/worker-css-b70c72a0.js"; +const htmlWorkerUrl = "/assets/worker-html-63aac2ef.js"; +const phpWorkerUrl = "/assets/worker-php-47e5dfe7.js"; +const javascriptWorkerUrl = "/assets/worker-javascript-c4599136.js"; +ace.config.setModuleUrl("ace/mode/css_worker", cssWorkerUrl); +ace.config.setModuleUrl("ace/mode/html_worker", htmlWorkerUrl); +ace.config.setModuleUrl("ace/mode/php_worker", phpWorkerUrl); +ace.config.setModuleUrl("ace/mode/javascript_worker", javascriptWorkerUrl); +function AceEditor({ + mode, + onChange, + onIsValidChange, + defaultValue, + beautify = true, + editorRef: propsEditorRef +}) { + const isDarkMode = useIsDarkMode(); + const defaultRef = useRef(null); + const editorRef = propsEditorRef || defaultRef; + useEffect(() => { + if (beautify && editorRef.current) { + Beautify.beautify(editorRef.current.editor.session); + } + }, [beautify, editorRef]); + return /* @__PURE__ */ jsx( + AceEditorRender, + { + ref: editorRef, + width: "auto", + height: "auto", + wrapEnabled: true, + className: "absolute inset-0", + focus: true, + mode, + theme: isDarkMode ? "tomorrow_night" : "chrome", + enableBasicAutocompletion: true, + enableLiveAutocompletion: true, + defaultValue, + onChange, + editorProps: { $blockScrolling: true }, + commands: Beautify.commands, + onValidate: (annotations) => { + const isValid = annotations.filter((a) => a.type === "error").length === 0; + onIsValidChange(isValid); + } + } + ); +} +export { + AceEditor as default +}; +//# sourceMappingURL=ace-editor-481d400c.mjs.map diff --git a/bootstrap/ssr/assets/ace-editor-481d400c.mjs.map b/bootstrap/ssr/assets/ace-editor-481d400c.mjs.map new file mode 100755 index 0000000..b61941b --- /dev/null +++ b/bootstrap/ssr/assets/ace-editor-481d400c.mjs.map @@ -0,0 +1 @@ +{"version":3,"file":"ace-editor-481d400c.mjs","sources":["../../../node_modules/ace-builds/src-noconflict/worker-css.js?url","../../../node_modules/ace-builds/src-noconflict/worker-html.js?url","../../../node_modules/ace-builds/src-noconflict/worker-php.js?url","../../../node_modules/ace-builds/src-noconflict/worker-javascript.js?url","../../../common/resources/client/ace-editor/ace-editor.tsx"],"sourcesContent":["export default \"__VITE_ASSET__aff451cf__\"","export default \"__VITE_ASSET__bea50456__\"","export default \"__VITE_ASSET__a530ae0a__\"","export default \"__VITE_ASSET__91a578e2__\"","import ace from 'ace-builds/src-noconflict/ace';\nimport cssWorkerUrl from 'ace-builds/src-noconflict/worker-css?url';\nimport htmlWorkerUrl from 'ace-builds/src-noconflict/worker-html?url';\nimport phpWorkerUrl from 'ace-builds/src-noconflict/worker-php?url';\nimport javascriptWorkerUrl from 'ace-builds/src-noconflict/worker-javascript?url';\nimport React, {MutableRefObject, useEffect, useRef} from 'react';\nimport AceEditorRender from 'react-ace';\nimport ReactAce from 'react-ace';\nimport 'ace-builds/src-noconflict/mode-css';\nimport 'ace-builds/src-noconflict/mode-html';\nimport 'ace-builds/src-noconflict/mode-javascript';\nimport 'ace-builds/src-noconflict/mode-php_laravel_blade';\nimport 'ace-builds/src-noconflict/theme-chrome';\nimport 'ace-builds/src-noconflict/theme-tomorrow_night';\nimport 'ace-builds/src-noconflict/ext-language_tools';\nimport Beautify from 'ace-builds/src-noconflict/ext-beautify';\nimport {useIsDarkMode} from '../ui/themes/use-is-dark-mode';\n\nace.config.setModuleUrl('ace/mode/css_worker', cssWorkerUrl);\nace.config.setModuleUrl('ace/mode/html_worker', htmlWorkerUrl);\nace.config.setModuleUrl('ace/mode/php_worker', phpWorkerUrl);\nace.config.setModuleUrl('ace/mode/javascript_worker', javascriptWorkerUrl);\n\ninterface Props {\n mode: 'css' | 'html' | 'javascript' | 'php_laravel_blade';\n onChange: (value: string) => void;\n onIsValidChange: (isValid: boolean) => void;\n defaultValue: string;\n beautify?: boolean;\n editorRef?: MutableRefObject;\n}\nexport default function AceEditor({\n mode,\n onChange,\n onIsValidChange,\n defaultValue,\n beautify = true,\n editorRef: propsEditorRef,\n}: Props) {\n const isDarkMode = useIsDarkMode();\n const defaultRef = useRef(null);\n const editorRef = propsEditorRef || defaultRef;\n\n useEffect(() => {\n if (beautify && editorRef.current) {\n Beautify.beautify(editorRef.current.editor.session);\n }\n }, [beautify, editorRef]);\n\n return (\n {\n const isValid =\n annotations.filter(a => a.type === 'error').length === 0;\n onIsValidChange(isValid);\n }}\n />\n );\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,MAAe,eAAA;ACAf,MAAe,gBAAA;ACAf,MAAe,eAAA;ACAf,MAAe,sBAAA;ACkBf,IAAI,OAAO,aAAa,uBAAuB,YAAY;AAC3D,IAAI,OAAO,aAAa,wBAAwB,aAAa;AAC7D,IAAI,OAAO,aAAa,uBAAuB,YAAY;AAC3D,IAAI,OAAO,aAAa,8BAA8B,mBAAmB;AAUzE,SAAwB,UAAU;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX,WAAW;AACb,GAAU;AACR,QAAM,aAAa;AACb,QAAA,aAAa,OAAwB,IAAI;AAC/C,QAAM,YAAY,kBAAkB;AAEpC,YAAU,MAAM;AACV,QAAA,YAAY,UAAU,SAAS;AACjC,eAAS,SAAS,UAAU,QAAQ,OAAO,OAAO;AAAA,IACpD;AAAA,EAAA,GACC,CAAC,UAAU,SAAS,CAAC;AAGtB,SAAA;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,KAAK;AAAA,MACL,OAAM;AAAA,MACN,QAAO;AAAA,MACP,aAAW;AAAA,MACX,WAAU;AAAA,MACV,OAAK;AAAA,MACL;AAAA,MACA,OAAO,aAAa,mBAAmB;AAAA,MACvC,2BAAyB;AAAA,MACzB,0BAAwB;AAAA,MACxB;AAAA,MACA;AAAA,MACA,aAAa,EAAC,iBAAiB,KAAI;AAAA,MACnC,UAAU,SAAS;AAAA,MACnB,YAAY,CAAe,gBAAA;AACnB,cAAA,UACJ,YAAY,OAAO,CAAA,MAAK,EAAE,SAAS,OAAO,EAAE,WAAW;AACzD,wBAAgB,OAAO;AAAA,MACzB;AAAA,IAAA;AAAA,EAAA;AAGN;","x_google_ignoreList":[0,1,2,3]} \ No newline at end of file diff --git a/bootstrap/ssr/assets/admin-report-page-181c7a5d.mjs b/bootstrap/ssr/assets/admin-report-page-181c7a5d.mjs new file mode 100755 index 0000000..4b9e6a9 --- /dev/null +++ b/bootstrap/ssr/assets/admin-report-page-181c7a5d.mjs @@ -0,0 +1,73 @@ +import { jsxs, jsx } from "react/jsx-runtime"; +import { useState } from "react"; +import { u as useAdminReport, R as ReportDateSelector, A as AdminHeaderReport, V as VisitorsReportCharts } from "./admin-routes-06d8abf9.mjs"; +import { l as StaticPageTitle, T as Trans } from "../server-entry.mjs"; +import { D as DateRangePresets } from "./user-profile-link-0bca566c.mjs"; +import "react-router-dom"; +import "clsx"; +import "framer-motion"; +import "@react-stately/utils"; +import "@tanstack/react-query"; +import "./section-helper-708a2a0a.mjs"; +import "./OpenInNew-3b47a656.mjs"; +import "@react-aria/utils"; +import "react-hook-form"; +import "@internationalized/date"; +import "zustand"; +import "zustand/middleware"; +import "zustand/middleware/immer"; +import "deepmerge"; +import "react-colorful"; +import "@react-stately/color"; +import "immer"; +import "nanoid"; +import "deep-object-diff"; +import "dot-object"; +import "@react-aria/focus"; +import "nano-memoize"; +import "@tanstack/react-virtual"; +import "react-dom/server"; +import "process"; +import "http"; +import "axios"; +import "react-router-dom/server.mjs"; +import "slugify"; +import "@floating-ui/react-dom"; +import "react-merge-refs"; +import "react-dom"; +import "@internationalized/number"; +import "@react-aria/ssr"; +import "axios-retry"; +import "tus-js-client"; +import "react-use-cookie"; +import "mime-match"; +import "react-use-clipboard"; +import "fscreen"; +import "zustand/traditional"; +import "@react-aria/interactions"; +function AdminReportPage() { + const [dateRange, setDateRange] = useState(() => { + return DateRangePresets[2].getRangeValue(); + }); + const { isLoading, data } = useAdminReport({ dateRange }); + const title = /* @__PURE__ */ jsx(Trans, { message: "Visitors report" }); + return /* @__PURE__ */ jsxs("div", { className: "min-h-full gap-12 overflow-x-hidden p-12 md:gap-18 md:p-18", children: [ + /* @__PURE__ */ jsxs("div", { className: "mb-24 items-center justify-between gap-24 md:flex", children: [ + /* @__PURE__ */ jsx(StaticPageTitle, { children: title }), + /* @__PURE__ */ jsx("h1", { className: "mb-24 text-3xl font-light md:mb-0", children: title }), + /* @__PURE__ */ jsx(ReportDateSelector, { value: dateRange, onChange: setDateRange }) + ] }), + /* @__PURE__ */ jsx(AdminHeaderReport, { report: data == null ? void 0 : data.headerReport }), + /* @__PURE__ */ jsx( + VisitorsReportCharts, + { + report: data == null ? void 0 : data.visitorsReport, + isLoading + } + ) + ] }); +} +export { + AdminReportPage as default +}; +//# sourceMappingURL=admin-report-page-181c7a5d.mjs.map diff --git a/bootstrap/ssr/assets/admin-report-page-181c7a5d.mjs.map b/bootstrap/ssr/assets/admin-report-page-181c7a5d.mjs.map new file mode 100755 index 0000000..2f1e8b4 --- /dev/null +++ b/bootstrap/ssr/assets/admin-report-page-181c7a5d.mjs.map @@ -0,0 +1 @@ +{"version":3,"file":"admin-report-page-181c7a5d.mjs","sources":["../../../common/resources/client/admin/analytics/admin-report-page.tsx"],"sourcesContent":["import React, {useState} from 'react';\nimport {useAdminReport} from './use-admin-report';\nimport {Trans} from '../../i18n/trans';\nimport {StaticPageTitle} from '../../seo/static-page-title';\nimport {AdminHeaderReport} from '@common/admin/analytics/admin-header-report';\nimport {VisitorsReportCharts} from '@common/admin/analytics/visitors-report-charts';\nimport {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';\nimport {DateRangePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';\nimport {ReportDateSelector} from '@common/admin/analytics/report-date-selector';\n\nexport default function AdminReportPage() {\n const [dateRange, setDateRange] = useState(() => {\n // This week\n return DateRangePresets[2].getRangeValue();\n });\n const {isLoading, data} = useAdminReport({dateRange});\n const title = ;\n\n return (\n
\n
\n {title}\n

{title}

\n \n
\n \n \n
\n );\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAUA,SAAwB,kBAAkB;AACxC,QAAM,CAAC,WAAW,YAAY,IAAI,SAAyB,MAAM;AAExD,WAAA,iBAAiB,CAAC,EAAE;EAAc,CAC1C;AACD,QAAM,EAAC,WAAW,KAAA,IAAQ,eAAe,EAAC,WAAU;AACpD,QAAM,QAAQ,oBAAC,OAAM,EAAA,SAAQ,kBAAkB,CAAA;AAG7C,SAAA,qBAAC,OAAI,EAAA,WAAU,8DACb,UAAA;AAAA,IAAC,qBAAA,OAAA,EAAI,WAAU,qDACb,UAAA;AAAA,MAAA,oBAAC,mBAAiB,UAAM,MAAA,CAAA;AAAA,MACvB,oBAAA,MAAA,EAAG,WAAU,qCAAqC,UAAM,OAAA;AAAA,MACxD,oBAAA,oBAAA,EAAmB,OAAO,WAAW,UAAU,cAAc;AAAA,IAAA,GAChE;AAAA,IACC,oBAAA,mBAAA,EAAkB,QAAQ,6BAAM,aAAc,CAAA;AAAA,IAC/C;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,QAAQ,6BAAM;AAAA,QACd;AAAA,MAAA;AAAA,IACF;AAAA,EACF,EAAA,CAAA;AAEJ;"} \ No newline at end of file diff --git a/bootstrap/ssr/assets/admin-routes-06d8abf9.mjs b/bootstrap/ssr/assets/admin-routes-06d8abf9.mjs new file mode 100755 index 0000000..e9103f0 --- /dev/null +++ b/bootstrap/ssr/assets/admin-routes-06d8abf9.mjs @@ -0,0 +1,22478 @@ +var _a; +import { jsxs, jsx, Fragment as Fragment$1 } from "react/jsx-runtime"; +import { Outlet, Link, useLocation, Navigate, useNavigate, useParams, NavLink, useOutletContext, useRoutes } from "react-router-dom"; +import clsx from "clsx"; +import { u as useSettings, C as CustomMenu, T as Trans, a as useMediaQuery, g as getFromLocalStorage, s as setInLocalStorage, U as Underlay, c as createSvgIcon, N as Navbar, I as IconButton, b as apiClient, d as useLocalStorage, E as ErrorIcon, m as message, e as useIsMobileMediaQuery, f as useNumberFormatter, S as SelectForwardRef, h as Item, K as KeyboardArrowLeftIcon, i as useTrans, j as TextField, k as SearchIcon, o as opacityAnimation, P as ProgressBar, l as StaticPageTitle, q as queryClient, t as toast, n as showHttpErrorToast, D as DialogTrigger, B as Button, p as useDialogContext, r as ConfirmationDialog, v as errorStatusIs, w as IllustratedMessage, x as SvgImage, y as Dialog, z as DialogHeader, A as DialogBody, F as DialogFooter, G as onFormQueryError, H as useDateFormatter, J as Form$1, L as FormTextField, M as CheckIcon, O as CloseIcon, Q as Chip, R as FormattedDate, V as Tooltip, W as LoginIcon, X as ButtonBase, Y as getInputFieldClassNames, Z as FormImageSelector, _ as useValueLists, $ as DoneAllIcon, a0 as List, a1 as ListItem, a2 as Skeleton, a3 as createSvgIconFromTree, a4 as FormSelect, a5 as Section, a6 as MixedText, a7 as FileUploadProvider, a8 as useAppearanceEditorMode, a9 as ProgressCircle, aa as useNavigate$1, ab as useBootstrapData, ac as FullPageLoader, ad as LinkStyle, ae as SiteConfigContext, af as getBootstrapData, ag as ExternalLink, ah as MenuTrigger, ai as Menu, aj as Checkbox, ak as FormRadioGroup, al as FormRadio, am as DateFormatPresets, an as prettyBytes, ao as useSocialLogin, ap as useField, aq as Field, ar as useResendVerificationEmail, as as useUser, at as useUploadAvatar, au as useRemoveAvatar, av as openDialog, aw as openUploadWindow, ax as UploadInputType, ay as slugifyString, az as isAbsoluteUrl, aA as useProducts, aB as FormattedPrice, aC as KeyboardArrowDownIcon, aD as PageStatus, aE as SettingsIcon, aF as FormattedRelativeTime, aG as useActiveUpload, aH as useAutoFocus, aI as validateUpload, aJ as UploadedFile, aK as Disk, aL as RadioGroup, aM as Radio, aN as PageErrorMessage, aO as ComboBoxForwardRef, aP as PageMetaTags, aQ as WarningIcon, aR as UnfoldLessIcon, aS as UnfoldMoreIcon, aT as shallowEqual, aU as useSelectedLocale, aV as useThemeSelector, aW as lazyLoader, aX as AuthRoute, aY as useCustomPage, aZ as useCollator, a_ as loadFonts, a$ as closeDialog, b0 as NotFoundPage } from "../server-entry.mjs"; +import React, { createContext, useEffect, useMemo, useCallback, cloneElement, useContext, useState, useId, Fragment, useRef, forwardRef, Suspense, Children, isValidElement, memo, lazy } from "react"; +import { AnimatePresence, m } from "framer-motion"; +import { useControlledState } from "@react-stately/utils"; +import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"; +import { S as SectionHelper, u as useCancelSubscription, a as useResumeSubscription } from "./section-helper-708a2a0a.mjs"; +import { F as FilterOperator, a as FilterControlType, d as dateRangeToAbsoluteRange, D as DateRangePresets, h as hasNextPage, K as KeyboardArrowRightIcon, A as AddFilterButton, u as useBackendFilterUrlParams, b as useDatatableData, B as BackendFiltersUrlKey, c as FilterListSkeleton, e as FilterList, T as Table, f as DatatableDataQueryKey, g as AddIcon, i as useCurrentDateTime, j as useBaseDatePickerState, k as DatePickerField, l as DateRangeIcon, m as DateSegmentList, C as Calendar, n as FormSwitch, N as NameWithAvatar, o as ChipList, p as FormSlider, q as Accordion, r as AccordionItem, S as Switch, s as usePrevious, t as FormChipField, v as useSortable, w as TuneIcon, M as MoreVertIcon, x as ChipField, y as TabContext, z as DragPreview, E as moveItemInNewArray, G as DragHandleIcon, H as Tabs, I as TabList, J as Tab, L as CrupdateResourceLayout, O as ImageZoomDialog, P as useNormalizedModels, Q as Avatar, R as ChevronLeftIcon, U as FormNormalizedModelField, V as playlist, W as useChannel, X as channelContentConfig, Y as GENRE_MODEL, Z as PRODUCTION_COUNTRY_MODEL, _ as TITLE_MODEL, $ as MOVIE_MODEL, a0 as SERIES_MODEL, a1 as ContentModelField, a2 as ContentOrderField, a3 as ChannelContentEditor, a4 as ChannelContentSearchField, a5 as ChannelContentItemImage, a6 as EMPTY_PAGINATION_RESPONSE, a7 as TableContext, a8 as NewsArticleImage, a9 as NewsArticleLink, aa as useDeleteComments, ab as UserAvatar, ac as useDeleteReviews, ad as TitleRating, ae as BulletSeparatedItems, af as StarSelector, ag as ALL_PRIMITIVE_OPERATORS, ah as ReviewListSortButton, ai as TitlePoster, aj as getWatchLink, ak as CompactSeasonEpisode, al as FormattedNumber, am as useNormalizedModel, an as FilterListItemDialogTrigger, ao as Input, ap as useIsTouchDevice, aq as VideoPlayerSkeleton, ar as SiteVideoPlayer, as as ArrowBackIcon, at as TitleLink, au as FormDateRangePicker, av as useTitleIndexFilters, aw as useTitle, ax as seasonQueryKey, ay as useStickySentinel, az as getTitleLink, aA as useScrollToTop, aB as useSeason, aC as TvIcon, aD as useSeasonEpisodes, aE as EpisodeListItem, aF as InfiniteScrollSentinel, aG as titleSeasonsQueryKey, aH as useTitleSeasons, aI as SeasonPoster, aJ as SeasonLink, aK as TitleBackdrop, aL as ImageIcon, aM as SortIcon, aN as useInfiniteData, aO as MediaPlayIcon, aP as VideoThumbnail, aQ as VideoGridItemBottomGradient, aR as PlayCircleIcon, aS as useEpisode, aT as PersonPoster, aU as TableRow, aV as PersonLink, aW as KnownForCompact, aX as PERSON_MODEL, aY as usePerson, aZ as useNewsArticle, a_ as todoImage, a$ as FormattedDateTimeRange, b0 as useDateRangePickerState, b1 as DateRangeComparePresets, b2 as DateRangeDialog, b3 as EpisodePoster, b4 as UserProfileLink, b5 as EpisodeLink, b6 as TitleLinkWithEpisodeNumber, b7 as CreateUserListPage, b8 as EditUserListPage } from "./user-profile-link-0bca566c.mjs"; +import { E as EditIcon, B as Breadcrumb, a as BreadcrumbItem, C as ChevronRightIcon, O as OpenInNewIcon } from "./OpenInNew-3b47a656.mjs"; +import { useController, useForm, useFormContext, useFieldArray, FormProvider } from "react-hook-form"; +import { toCalendarDate, toZoned, isSameDay, parseAbsoluteToLocal, DateFormatter } from "@internationalized/date"; +import { mergeProps, useLayoutEffect, useObjectRef, isMac } from "@react-aria/utils"; +import { create } from "zustand"; +import { subscribeWithSelector } from "zustand/middleware"; +import { immer } from "zustand/middleware/immer"; +import deepMerge from "deepmerge"; +import { HexColorPicker, HexColorInput } from "react-colorful"; +import { parseColor } from "@react-stately/color"; +import { produce } from "immer"; +import { nanoid } from "nanoid"; +import { diff } from "deep-object-diff"; +import dot from "dot-object"; +import { getFocusableTreeWalker } from "@react-aria/focus"; +import memoize from "nano-memoize"; +import { useVirtualizer } from "@tanstack/react-virtual"; +function AdminSidebar({ className, isCompactMode }) { + const { version } = useSettings(); + return /* @__PURE__ */ jsxs( + "div", + { + className: clsx( + className, + "relative flex flex-col gap-20 overflow-y-auto border-r bg-alt px-12 pb-16 pt-26 text-sm font-medium text-muted" + ), + children: [ + /* @__PURE__ */ jsx( + CustomMenu, + { + matchDescendants: (to) => to === "/admin", + menu: "admin-sidebar", + orientation: "vertical", + onlyShowIcons: isCompactMode, + itemClassName: ({ isActive }) => clsx( + "block w-full rounded-button py-12 px-16", + isActive ? "bg-primary/6 text-primary font-semibold" : "hover:bg-hover" + ), + gap: "gap-8" + } + ), + !isCompactMode && /* @__PURE__ */ jsx("div", { className: "mt-auto gap-14 px-16 text-xs", children: /* @__PURE__ */ jsx(Trans, { message: "Version: :number", values: { number: version } }) }) + ] + } + ); +} +const DashboardLayoutContext = createContext( + null +); +function useBlockBodyOverflow(disable = false) { + useEffect(() => { + if (disable) { + document.documentElement.classList.remove("no-page-overflow"); + } else { + document.documentElement.classList.add("no-page-overflow"); + } + return () => { + document.documentElement.classList.remove("no-page-overflow"); + }; + }, [disable]); +} +function DashboardLayout({ + children, + leftSidenavStatus: leftSidenav, + onLeftSidenavChange, + rightSidenavStatus: rightSidenav, + initialRightSidenavStatus, + onRightSidenavChange, + name, + leftSidenavCanBeCompact, + height = "h-screen", + className, + gridClassName = "dashboard-grid", + blockBodyOverflow = true, + ...domProps +}) { + useBlockBodyOverflow(!blockBodyOverflow); + const isMobile = useMediaQuery("(max-width: 1024px)"); + const isCompactModeInitially = useMemo(() => { + return !name ? false : getFromLocalStorage(`${name}.sidenav.compact`); + }, [name]); + const defaultLeftSidenavStatus = isCompactModeInitially ? "compact" : "open"; + const [leftSidenavStatus, setLeftSidenavStatus] = useControlledState( + leftSidenav, + isMobile ? "closed" : defaultLeftSidenavStatus, + onLeftSidenavChange + ); + const rightSidenavStatusDefault = useMemo(() => { + if (isMobile) { + return "closed"; + } + if (initialRightSidenavStatus != null) { + return initialRightSidenavStatus; + } + const userSelected = getFromLocalStorage( + `${name}.sidenav.right.position`, + "open" + ); + if (userSelected != null) { + return userSelected; + } + return initialRightSidenavStatus || "closed"; + }, [isMobile, name, initialRightSidenavStatus]); + const [rightSidenavStatus, _setRightSidenavStatus] = useControlledState( + rightSidenav, + rightSidenavStatusDefault, + onRightSidenavChange + ); + const setRightSidenavStatus = useCallback( + (status) => { + _setRightSidenavStatus(status); + setInLocalStorage(`${name}.sidenav.right.position`, status); + }, + [_setRightSidenavStatus, name] + ); + const shouldShowUnderlay = isMobile && (leftSidenavStatus === "open" || rightSidenavStatus === "open"); + return /* @__PURE__ */ jsx( + DashboardLayoutContext.Provider, + { + value: { + leftSidenavStatus, + setLeftSidenavStatus, + rightSidenavStatus, + setRightSidenavStatus, + leftSidenavCanBeCompact, + name, + isMobileMode: isMobile + }, + children: /* @__PURE__ */ jsxs( + "div", + { + ...domProps, + className: clsx("relative isolate", gridClassName, className, height), + children: [ + children, + /* @__PURE__ */ jsx(AnimatePresence, { children: shouldShowUnderlay && /* @__PURE__ */ jsx( + Underlay, + { + position: "fixed", + onClick: () => { + setLeftSidenavStatus("closed"); + setRightSidenavStatus("closed"); + } + }, + "dashboard-underlay" + ) }) + ] + } + ) + } + ); +} +function DashboardContent({ + children, + isScrollable = true +}) { + return cloneElement(children, { + className: clsx( + children.props.className, + isScrollable && "overflow-y-auto stable-scrollbar", + "dashboard-grid-content" + ) + }); +} +function DashboardSidenav({ + className, + position, + children, + size = "md", + mode, + overlayPosition = "fixed", + display = "flex", + overflow = "overflow-hidden", + forceClosed = false +}) { + const { + isMobileMode, + leftSidenavStatus, + setLeftSidenavStatus, + rightSidenavStatus, + setRightSidenavStatus + } = useContext(DashboardLayoutContext); + const status = position === "left" ? leftSidenavStatus : rightSidenavStatus; + const isOverlayMode = isMobileMode || mode === "overlay"; + const variants = { + open: { display, width: null }, + compact: { + display, + width: null + }, + closed: { + width: 0, + transitionEnd: { + display: "none" + } + } + }; + const sizeClassName = getSize(status === "compact" ? "compact" : size); + return /* @__PURE__ */ jsx( + m.div, + { + variants, + initial: false, + animate: forceClosed ? "closed" : status, + transition: { type: "tween", duration: 0.15 }, + onClick: (e) => { + const target = e.target; + if (isMobileMode && (target.closest("button") || target.closest("a"))) { + setLeftSidenavStatus("closed"); + setRightSidenavStatus("closed"); + } + }, + className: clsx( + className, + position === "left" ? "dashboard-grid-sidenav-left" : "dashboard-grid-sidenav-right", + "will-change-[width]", + overflow, + sizeClassName, + isOverlayMode && `${overlayPosition} bottom-0 top-0 z-20 shadow-2xl`, + isOverlayMode && position === "left" && "left-0", + isOverlayMode && position === "right" && "right-0" + ), + children: cloneElement(children, { + className: clsx( + children.props.className, + "w-full h-full", + status === "compact" && "compact-scrollbar" + ), + isCompactMode: status === "compact" + }) + } + ); +} +function getSize(size) { + switch (size) { + case "compact": + return "w-80"; + case "sm": + return "w-224"; + case "md": + return "w-240"; + case "lg": + return "w-288"; + default: + return size || ""; + } +} +const MenuOpenIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M3 18h13v-2H3v2zm0-5h10v-2H3v2zm0-7v2h13V6H3zm18 9.59L17.42 12 21 8.41 19.59 7l-5 5 5 5L21 15.59z" }), + "MenuOpenOutlined" +); +function DashboardNavbar({ + children, + className, + hideToggleButton, + ...props +}) { + const { + isMobileMode, + leftSidenavStatus, + setLeftSidenavStatus, + name, + leftSidenavCanBeCompact + } = useContext(DashboardLayoutContext); + const shouldToggleCompactMode = leftSidenavCanBeCompact && !isMobileMode; + const shouldShowToggle = !hideToggleButton && (isMobileMode || leftSidenavCanBeCompact); + const handleToggle = () => { + setLeftSidenavStatus(leftSidenavStatus === "open" ? "closed" : "open"); + }; + const handleCompactModeToggle = () => { + const newStatus = leftSidenavStatus === "compact" ? "open" : "compact"; + setInLocalStorage(`${name}.sidenav.compact`, newStatus === "compact"); + setLeftSidenavStatus(newStatus); + }; + return /* @__PURE__ */ jsx( + Navbar, + { + className: clsx("dashboard-grid-navbar", className), + border: "border-b", + size: "sm", + toggleButton: shouldShowToggle ? /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + onClick: () => { + if (shouldToggleCompactMode) { + handleCompactModeToggle(); + } else { + handleToggle(); + } + }, + children: /* @__PURE__ */ jsx(MenuOpenIcon, {}) + } + ) : void 0, + ...props, + children + } + ); +} +function useAdminSetupAlerts() { + return useQuery({ + queryKey: ["admin-setup-alerts"], + queryFn: () => fetchAlerts() + }); +} +function fetchAlerts() { + return apiClient.get(`admin/setup-alerts`).then((response) => response.data); +} +function AdminLayout() { + return /* @__PURE__ */ jsxs(DashboardLayout, { name: "admin", leftSidenavCanBeCompact: true, children: [ + /* @__PURE__ */ jsx(DashboardNavbar, { size: "sm", menuPosition: "admin-navbar" }), + /* @__PURE__ */ jsx(DashboardSidenav, { position: "left", size: "sm", children: /* @__PURE__ */ jsx(AdminSidebar, {}) }), + /* @__PURE__ */ jsx(DashboardContent, { children: /* @__PURE__ */ jsxs("div", { className: "bg dark:bg-alt", children: [ + /* @__PURE__ */ jsx(SetupAlertsList, {}), + /* @__PURE__ */ jsx(Outlet, {}) + ] }) }) + ] }); +} +function SetupAlertsList() { + const { data } = useAdminSetupAlerts(); + const [dismissValue] = useLocalStorage("admin-setup-alert-dismissed", null); + const shouldShowAlert = !dismissValue || Date.now() - dismissValue.timestamp > 864e5; + if (!(data == null ? void 0 : data.alerts.length) || !shouldShowAlert) { + return null; + } + return /* @__PURE__ */ jsx("div", { className: "fixed left-0 right-0 top-24 z-10 mx-auto w-max overflow-hidden rounded-panel bg shadow-md", children: /* @__PURE__ */ jsx(SetupAlert, { alert: data.alerts[0] }) }); +} +function SetupAlert({ alert }) { + const description = /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: { __html: alert.description } }); + return /* @__PURE__ */ jsx( + SectionHelper, + { + leadingIcon: /* @__PURE__ */ jsx(ErrorIcon, { size: "xs", className: "text-danger" }), + onClose: () => { + setInLocalStorage("admin-setup-alert-dismissed", { + timestamp: Date.now() + }); + }, + title: alert.title, + description, + color: "neutral" + }, + alert.title + ); +} +function timestampFilter(options) { + var _a2; + return { + ...options, + defaultOperator: FilterOperator.between, + control: { + type: FilterControlType.DateRangePicker, + defaultValue: ((_a2 = options.control) == null ? void 0 : _a2.defaultValue) || dateRangeToAbsoluteRange( + DateRangePresets[3].getRangeValue() + ) + } + }; +} +function createdAtFilter(options) { + return timestampFilter({ + key: "created_at", + label: message("Date created"), + ...options + }); +} +function updatedAtFilter(options) { + return timestampFilter({ + key: "updated_at", + label: message("Last updated"), + ...options + }); +} +const UserDatatableFilters = [ + { + key: "email_verified_at", + label: message("Email"), + description: message("Email verification status"), + defaultOperator: FilterOperator.ne, + control: { + type: FilterControlType.Select, + defaultValue: "01", + options: [ + { + key: "01", + label: message("is confirmed"), + value: { value: null, operator: FilterOperator.ne } + }, + { + key: "02", + label: message("is not confirmed"), + value: { value: null, operator: FilterOperator.eq } + } + ] + } + }, + createdAtFilter({ + description: message("Date user registered or was created") + }), + updatedAtFilter({ + description: message("Date user was last updated") + }), + { + key: "subscriptions", + label: message("Subscription"), + description: message("Whether user is subscribed or not"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "01", + options: [ + { + key: "01", + label: message("is subscribed"), + value: { value: "*", operator: FilterOperator.has } + }, + { + key: "02", + label: message("is not subscribed"), + value: { value: "*", operator: FilterOperator.doesntHave } + } + ] + } + } +]; +const DataTableContext = React.createContext( + null +); +function useDataTable() { + return useContext(DataTableContext); +} +const defaultPerPage = 15; +const perPageOptions = [{ key: 10 }, { key: 15 }, { key: 20 }, { key: 50 }, { key: 100 }]; +function DataTablePaginationFooter({ + query, + onPerPageChange, + onPageChange, + className +}) { + var _a2; + const isMobile = useIsMobileMediaQuery(); + const numberFormatter = useNumberFormatter(); + const pagination = (_a2 = query.data) == null ? void 0 : _a2.pagination; + if (!pagination) + return null; + const perPageSelect = onPerPageChange ? /* @__PURE__ */ jsx( + SelectForwardRef, + { + minWidth: "min-w-auto", + selectionMode: "single", + disabled: query.isLoading, + labelPosition: "side", + size: "xs", + label: /* @__PURE__ */ jsx(Trans, { message: "Items per page" }), + selectedValue: pagination.per_page || defaultPerPage, + onSelectionChange: (value) => onPerPageChange(value), + children: perPageOptions.map((option) => /* @__PURE__ */ jsx(Item, { value: option.key, children: option.key }, option.key)) + } + ) : null; + return /* @__PURE__ */ jsxs( + "div", + { + className: clsx( + "flex h-54 select-none items-center justify-end gap-20 px-20", + className + ), + children: [ + !isMobile && perPageSelect, + pagination.from && pagination.to && "total" in pagination ? /* @__PURE__ */ jsx("div", { className: "text-sm", children: /* @__PURE__ */ jsx( + Trans, + { + message: ":from - :to of :total", + values: { + from: pagination.from, + to: pagination.to, + total: numberFormatter.format(pagination.total) + } + } + ) }) : null, + /* @__PURE__ */ jsxs("div", { className: "text-muted", children: [ + /* @__PURE__ */ jsx( + IconButton, + { + disabled: query.isFetching || pagination.current_page < 2, + onClick: () => { + onPageChange == null ? void 0 : onPageChange((pagination == null ? void 0 : pagination.current_page) - 1); + }, + children: /* @__PURE__ */ jsx(KeyboardArrowLeftIcon, {}) + } + ), + /* @__PURE__ */ jsx( + IconButton, + { + disabled: query.isFetching || !hasNextPage(pagination), + onClick: () => { + onPageChange == null ? void 0 : onPageChange((pagination == null ? void 0 : pagination.current_page) + 1); + }, + children: /* @__PURE__ */ jsx(KeyboardArrowRightIcon, {}) + } + ) + ] }) + ] + } + ); +} +function DataTableHeader({ + actions, + filters, + filtersLoading, + searchPlaceholder = message("Type to search..."), + searchValue = "", + onSearchChange +}) { + const { trans } = useTrans(); + return /* @__PURE__ */ jsxs(HeaderLayout, { children: [ + /* @__PURE__ */ jsx( + TextField, + { + size: "sm", + className: "mr-auto min-w-180 max-w-440 flex-auto", + inputWrapperClassName: "mr-24 md:mr-0", + placeholder: trans(searchPlaceholder), + startAdornment: /* @__PURE__ */ jsx(SearchIcon, { size: "sm" }), + value: searchValue, + onChange: (e) => { + onSearchChange(e.target.value); + } + } + ), + filters && /* @__PURE__ */ jsx(AddFilterButton, { filters, disabled: filtersLoading }), + actions + ] }); +} +function HeaderLayout({ children, ...domProps }) { + return /* @__PURE__ */ jsx( + "div", + { + className: "hidden-scrollbar relative mb-24 flex h-42 items-center gap-8 overflow-x-auto text-muted md:gap-12", + ...domProps, + children + } + ); +} +function SelectedStateDatatableHeader({ + actions, + selectedItemsCount +}) { + return /* @__PURE__ */ jsxs(HeaderLayout, { "data-testid": "datatable-selected-header", children: [ + /* @__PURE__ */ jsx("div", { className: "mr-auto", children: /* @__PURE__ */ jsx( + Trans, + { + message: "[one 1 item|other :count items] selected", + values: { count: selectedItemsCount } + } + ) }), + actions + ] }); +} +function DataTable({ + filters, + filtersLoading, + columns, + searchPlaceholder, + queryParams, + endpoint: endpoint2, + actions, + selectedActions, + emptyStateMessage, + tableDomProps, + onRowAction, + enableSelection = true, + selectionStyle = "checkbox", + children, + cellHeight, + collapseTableOnMobile = true +}) { + var _a2; + const isMobile = useIsMobileMediaQuery(); + const { trans } = useTrans(); + const { encodedFilters } = useBackendFilterUrlParams(filters); + const [params, setParams] = useState({ perPage: 15 }); + const [selectedRows, setSelectedRows] = useState([]); + const query = useDatatableData( + endpoint2, + { + ...params, + ...queryParams, + [BackendFiltersUrlKey]: encodedFilters + }, + void 0, + () => setSelectedRows([]) + ); + const isFiltering = !!(params.query || params.filters || encodedFilters); + const pagination = (_a2 = query.data) == null ? void 0 : _a2.pagination; + return /* @__PURE__ */ jsxs( + DataTableContext.Provider, + { + value: { + selectedRows, + setSelectedRows, + endpoint: endpoint2, + params, + setParams, + query + }, + children: [ + children, + /* @__PURE__ */ jsx(AnimatePresence, { initial: false, mode: "wait", children: selectedRows.length ? /* @__PURE__ */ jsx( + SelectedStateDatatableHeader, + { + selectedItemsCount: selectedRows.length, + actions: selectedActions + }, + "selected" + ) : /* @__PURE__ */ jsx( + DataTableHeader, + { + searchPlaceholder, + searchValue: params.query, + onSearchChange: (query2) => setParams({ ...params, query: query2 }), + actions, + filters, + filtersLoading + }, + "default" + ) }), + filters && /* @__PURE__ */ jsx("div", { className: "mb-14", children: /* @__PURE__ */ jsx(AnimatePresence, { initial: false, mode: "wait", children: filtersLoading && encodedFilters ? /* @__PURE__ */ jsx(FilterListSkeleton, {}) : /* @__PURE__ */ jsx(m.div, { ...opacityAnimation, children: /* @__PURE__ */ jsx(FilterList, { filters }) }, "filter-list") }) }), + /* @__PURE__ */ jsxs( + "div", + { + className: clsx( + "relative rounded-panel", + (!isMobile || !collapseTableOnMobile) && "border" + ), + children: [ + query.isFetching && /* @__PURE__ */ jsx( + ProgressBar, + { + isIndeterminate: true, + className: "absolute left-0 top-0 z-10 w-full", + "aria-label": trans({ message: "Loading" }), + size: "xs" + } + ), + /* @__PURE__ */ jsx("div", { className: "relative overflow-x-auto md:overflow-hidden", children: /* @__PURE__ */ jsx( + Table, + { + ...tableDomProps, + columns, + data: (pagination == null ? void 0 : pagination.data) || [], + sortDescriptor: params, + onSortChange: (descriptor) => { + setParams({ ...params, ...descriptor }); + }, + selectedRows, + enableSelection, + selectionStyle, + onSelectionChange: setSelectedRows, + onAction: onRowAction, + collapseOnMobile: collapseTableOnMobile, + cellHeight + } + ) }), + (query.isFetched || query.isPlaceholderData) && !(pagination == null ? void 0 : pagination.data.length) ? /* @__PURE__ */ jsx("div", { className: "pt-50", children: cloneElement(emptyStateMessage, { + isFiltering + }) }) : void 0, + /* @__PURE__ */ jsx( + DataTablePaginationFooter, + { + query, + onPageChange: (page) => setParams({ ...params, page }), + onPerPageChange: (perPage) => setParams({ ...params, perPage }) + } + ) + ] + } + ) + ] + } + ); +} +function DataTablePage({ + title, + headerContent, + headerItemsAlign = "items-end", + className, + padding, + ...dataTableProps +}) { + const titleId = useId(); + return /* @__PURE__ */ jsxs("div", { className: clsx(padding ?? "p-12 md:p-24", className), children: [ + title && /* @__PURE__ */ jsxs( + "div", + { + className: clsx( + "mb-16", + headerContent && `flex ${headerItemsAlign} gap-4` + ), + children: [ + /* @__PURE__ */ jsx(StaticPageTitle, { children: title }), + /* @__PURE__ */ jsx("h1", { className: "text-3xl font-light first:capitalize", id: titleId, children: title }), + headerContent + ] + } + ), + /* @__PURE__ */ jsx( + DataTable, + { + ...dataTableProps, + tableDomProps: { + "aria-labelledby": title ? titleId : void 0 + } + } + ) + ] }); +} +function useDeleteSelectedRows() { + const { endpoint: endpoint2, selectedRows, setSelectedRows } = useDataTable(); + return useMutation({ + mutationFn: () => deleteSelectedRows(endpoint2, selectedRows), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey(endpoint2) + }); + toast( + message("Deleted [one 1 record|other :count records]", { + values: { count: selectedRows.length } + }) + ); + setSelectedRows([]); + }, + onError: (err) => showHttpErrorToast(err, message("Could not delete records")) + }); +} +function deleteSelectedRows(endpoint2, ids) { + return apiClient.delete(`${endpoint2}/${ids.join(",")}`).then((r) => r.data); +} +function DeleteSelectedItemsAction() { + return /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(Button, { variant: "flat", color: "danger", className: "ml-auto", children: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) }), + /* @__PURE__ */ jsx(DeleteItemsDialog, {}) + ] }); +} +function DeleteItemsDialog() { + const deleteSelectedRows2 = useDeleteSelectedRows(); + const { selectedRows, setSelectedRows } = useDataTable(); + const { close } = useDialogContext(); + return /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isLoading: deleteSelectedRows2.isPending, + title: /* @__PURE__ */ jsx( + Trans, + { + message: "Delete [one 1 item|other :count items]?", + values: { count: selectedRows.length } + } + ), + body: /* @__PURE__ */ jsx(Trans, { message: "This will permanently remove the items and cannot be undone." }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }), + isDanger: true, + onConfirm: () => { + deleteSelectedRows2.mutate(void 0, { + onSuccess: () => close(), + onError: (err) => { + if (errorStatusIs(err, 422)) { + setSelectedRows([]); + close(); + } + } + }); + } + } + ); +} +function DataTableEmptyStateMessage({ + isFiltering, + title, + filteringTitle, + image, + size, + className +}) { + const isMobile = useIsMobileMediaQuery(); + if (!size) { + size = isMobile ? "sm" : "md"; + } + return /* @__PURE__ */ jsx( + IllustratedMessage, + { + className, + size, + image: /* @__PURE__ */ jsx(SvgImage, { src: image }), + title: isFiltering && filteringTitle ? filteringTitle : title, + description: isFiltering && filteringTitle ? /* @__PURE__ */ jsx(Trans, { message: "Try another search query or different filters" }) : void 0 + } + ); +} +const teamSvg = "/assets/team-de984127.svg"; +const DataTableAddItemButton = React.forwardRef( + ({ children, to, elementType, onClick, href, download, icon, disabled }, ref) => { + const isMobile = useIsMobileMediaQuery(); + if (isMobile) { + return /* @__PURE__ */ jsx( + IconButton, + { + ref, + variant: "flat", + color: "primary", + className: "flex-shrink-0", + size: "sm", + to, + href, + download, + elementType, + onClick, + disabled, + children: icon || /* @__PURE__ */ jsx(AddIcon, {}) + } + ); + } + return /* @__PURE__ */ jsx( + Button, + { + ref, + startIcon: icon || /* @__PURE__ */ jsx(AddIcon, {}), + variant: "flat", + color: "primary", + size: "sm", + to, + href, + download, + elementType, + onClick, + disabled, + children + } + ); + } +); +const FileDownloadIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M18 15v3H6v-3H4v3c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-3h-2zm-1-4-1.41-1.41L13 12.17V4h-2v8.17L8.41 9.59 7 11l5 5 5-5z" }), + "FileDownloadOutlined" +); +function useExportCsv(endpoint2) { + return useMutation({ + mutationFn: (payload) => exportCsv(endpoint2, payload), + onError: (err) => showHttpErrorToast(err) + }); +} +function exportCsv(endpoint2, payload) { + return apiClient.post(endpoint2, payload).then((r) => r.data); +} +function downloadFileFromUrl(url, name) { + const link = document.createElement("a"); + link.href = url; + if (name) + link.download = name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} +function CsvExportInfoDialog() { + const { close } = useDialogContext(); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Csv export" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsx( + Trans, + { + message: "Your request is being processed. We'll email you when the report is ready to download. In\n certain cases, it might take a little longer, depending on the number of items beings\n exported and the volume of activity." + } + ) }), + /* @__PURE__ */ jsx(DialogFooter, { children: /* @__PURE__ */ jsx(Button, { variant: "flat", color: "primary", onClick: close, children: /* @__PURE__ */ jsx(Trans, { message: "Got it" }) }) }) + ] }); +} +function DataTableExportCsvButton({ + endpoint: endpoint2, + payload +}) { + const [dialogIsOpen, setDialogIsOpen] = useState(false); + const exportCsv2 = useExportCsv(endpoint2); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + IconButton, + { + variant: "outline", + color: "primary", + size: "sm", + className: "flex-shrink-0", + disabled: exportCsv2.isPending, + onClick: () => { + exportCsv2.mutate(payload, { + onSuccess: (response) => { + if (response.downloadPath) { + downloadFileFromUrl(response.downloadPath); + } else { + setDialogIsOpen(true); + } + } + }); + }, + children: /* @__PURE__ */ jsx(FileDownloadIcon, {}) + } + ), + /* @__PURE__ */ jsx( + DialogTrigger, + { + type: "modal", + isOpen: dialogIsOpen, + onOpenChange: setDialogIsOpen, + children: /* @__PURE__ */ jsx(CsvExportInfoDialog, {}) + } + ) + ] }); +} +const PersonOffIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "m20 17.17-3.37-3.38c.64.22 1.23.48 1.77.76.97.51 1.58 1.52 1.6 2.62zm1.19 4.02-1.41 1.41-2.61-2.6H4v-2.78c0-1.12.61-2.15 1.61-2.66 1.29-.66 2.87-1.22 4.67-1.45L1.39 4.22 2.8 2.81l18.39 18.38zM15.17 18l-3-3H12c-2.37 0-4.29.73-5.48 1.34-.32.16-.52.5-.52.88V18h9.17zM12 6c1.1 0 2 .9 2 2 0 .86-.54 1.59-1.3 1.87l1.48 1.48C15.28 10.64 16 9.4 16 8c0-2.21-1.79-4-4-4-1.4 0-2.64.72-3.35 1.82l1.48 1.48C10.41 6.54 11.14 6 12 6z" }), + "PersonOffOutlined" +); +function useBanUser(form, userId) { + return useMutation({ + mutationFn: (payload) => banUser(userId, payload), + onSuccess: async () => { + toast(message("User suspended")); + await queryClient.invalidateQueries({ queryKey: ["users"] }); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function banUser(userId, payload) { + return apiClient.post(`users/${userId}/ban`, payload).then((r) => r.data); +} +function useDatePickerState(props) { + const now = useCurrentDateTime(); + const [isPlaceholder, setIsPlaceholder] = useState( + !props.value && !props.defaultValue + ); + const setStateValue = props.onChange; + const [internalValue, setInternalValue] = useControlledState( + props.value || now, + props.defaultValue || now, + (value) => { + setIsPlaceholder(false); + setStateValue == null ? void 0 : setStateValue(value); + } + ); + const { + min, + max, + granularity, + timezone, + calendarIsOpen, + setCalendarIsOpen, + closeDialogOnSelection + } = useBaseDatePickerState(internalValue, props); + const clear = useCallback(() => { + setIsPlaceholder(true); + setInternalValue(now); + setStateValue == null ? void 0 : setStateValue(null); + setCalendarIsOpen(false); + }, [now, setInternalValue, setStateValue, setCalendarIsOpen]); + const [calendarDates, setCalendarDates] = useState(() => { + return [toCalendarDate(internalValue)]; + }); + const setSelectedValue = useCallback( + (newValue) => { + if (min && newValue.compare(min) < 0) { + newValue = min; + } else if (max && newValue.compare(max) > 0) { + newValue = max; + } + const value = internalValue ? internalValue.set(newValue) : toZoned(newValue, timezone); + setInternalValue(value); + setCalendarDates([toCalendarDate(value)]); + setIsPlaceholder(false); + }, + [setInternalValue, min, max, internalValue, timezone] + ); + const dayIsActive = useCallback( + (day) => !isPlaceholder && isSameDay(internalValue, day), + [internalValue, isPlaceholder] + ); + const getCellProps = useCallback( + (date) => { + return { + onClick: () => { + setSelectedValue == null ? void 0 : setSelectedValue(date); + if (closeDialogOnSelection) { + setCalendarIsOpen == null ? void 0 : setCalendarIsOpen(false); + } + } + }; + }, + [setSelectedValue, setCalendarIsOpen, closeDialogOnSelection] + ); + return { + selectedValue: internalValue, + setSelectedValue: setInternalValue, + calendarIsOpen, + setCalendarIsOpen, + dayIsActive, + dayIsHighlighted: () => false, + dayIsRangeStart: () => false, + dayIsRangeEnd: () => false, + getCellProps, + calendarDates, + setCalendarDates, + isPlaceholder, + clear, + setIsPlaceholder, + min, + max, + granularity, + timezone, + closeDialogOnSelection + }; +} +function DatePicker({ showCalendarFooter, ...props }) { + const state = useDatePickerState(props); + const inputRef = useRef(null); + const now = useCurrentDateTime(); + const footer = showCalendarFooter && /* @__PURE__ */ jsx( + DialogFooter, + { + padding: "px-14 pb-14", + startAction: /* @__PURE__ */ jsx( + Button, + { + disabled: state.isPlaceholder, + variant: "text", + color: "primary", + onClick: () => { + state.clear(); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Clear" }) + } + ), + children: /* @__PURE__ */ jsx( + Button, + { + variant: "text", + color: "primary", + onClick: () => { + state.setSelectedValue(now); + state.setCalendarIsOpen(false); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Today" }) + } + ) + } + ); + const dialog = /* @__PURE__ */ jsx( + DialogTrigger, + { + offset: 8, + placement: "bottom-start", + isOpen: state.calendarIsOpen, + onOpenChange: state.setCalendarIsOpen, + type: "popover", + triggerRef: inputRef, + returnFocusToTrigger: false, + moveFocusToDialog: false, + children: /* @__PURE__ */ jsxs(Dialog, { size: "auto", children: [ + /* @__PURE__ */ jsx( + DialogBody, + { + className: "flex items-start gap-40", + padding: showCalendarFooter ? "px-24 pt-20 pb-10" : null, + children: /* @__PURE__ */ jsx(Calendar, { state, visibleMonths: 1 }) + } + ), + footer + ] }) + } + ); + const openOnClick = { + onClick: (e) => { + e.stopPropagation(); + e.preventDefault(); + if (!isHourSegment(e)) { + state.setCalendarIsOpen(true); + } else { + state.setCalendarIsOpen(false); + } + } + }; + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + DatePickerField, + { + ref: inputRef, + wrapperProps: openOnClick, + endAdornment: /* @__PURE__ */ jsx(DateRangeIcon, { className: clsx(props.disabled && "text-disabled") }), + ...props, + children: /* @__PURE__ */ jsx( + DateSegmentList, + { + segmentProps: openOnClick, + state, + value: state.selectedValue, + onChange: state.setSelectedValue, + isPlaceholder: state.isPlaceholder + } + ) + } + ), + dialog + ] }); +} +function FormDatePicker(props) { + const { min, max } = props; + const { trans } = useTrans(); + const { format } = useDateFormatter(); + const { + field: { onChange, onBlur, value = null, ref }, + fieldState: { invalid, error } + } = useController({ + name: props.name, + rules: { + validate: (v) => { + if (!v) + return; + const date = parseAbsoluteToLocal(v); + if (min && date.compare(min) < 0) { + return trans({ + message: "Enter a date after :date", + values: { date: format(v) } + }); + } + if (max && date.compare(max) > 0) { + return trans({ + message: "Enter a date before :date", + values: { date: format(v) } + }); + } + } + } + }); + const parsedValue = value ? parseAbsoluteToLocal(value) : null; + const formProps = { + onChange: (e) => { + onChange(e ? e.toAbsoluteString() : e); + }, + onBlur, + value: parsedValue, + invalid, + errorMessage: error == null ? void 0 : error.message, + inputRef: ref + }; + return /* @__PURE__ */ jsx(DatePicker, { ...mergeProps(formProps, props) }); +} +function isHourSegment(e) { + return ["hour", "minute", "dayPeriod"].includes( + e.currentTarget.ariaLabel || "" + ); +} +function BanUserDialog({ user }) { + const { trans } = useTrans(); + const { close, formId } = useDialogContext(); + const form = useForm({ + defaultValues: { + permanent: true + } + }); + const isPermanent = form.watch("permanent"); + const banUser2 = useBanUser(form, user.id); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Suspend “:name“", values: { name: user.display_name } }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs( + Form$1, + { + id: formId, + form, + onSubmit: (values) => banUser2.mutate(values, { onSuccess: () => close() }), + children: [ + /* @__PURE__ */ jsx( + FormDatePicker, + { + name: "ban_until", + label: /* @__PURE__ */ jsx(Trans, { message: "Suspend until" }), + disabled: isPermanent + } + ), + /* @__PURE__ */ jsx(FormSwitch, { name: "permanent", className: "mt-12", children: /* @__PURE__ */ jsx(Trans, { message: "Permanent" }) }), + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mt-24", + name: "comment", + inputElementType: "textarea", + maxLength: 250, + label: /* @__PURE__ */ jsx(Trans, { message: "Reason" }), + placeholder: trans(message("Optional")) + } + ) + ] + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: () => close(), children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx( + Button, + { + form: formId, + variant: "flat", + color: "primary", + type: "submit", + disabled: banUser2.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Suspend" }) + } + ) + ] }) + ] }); +} +function useUnbanUser(userId) { + return useMutation({ + mutationFn: () => unbanUser(userId), + onSuccess: () => { + toast(message("User unsuspended")); + queryClient.invalidateQueries({ queryKey: ["users"] }); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function unbanUser(userId) { + return apiClient.delete(`users/${userId}/unban`).then((r) => r.data); +} +function useImpersonateUser() { + return useMutation({ + mutationFn: (payload) => impersonateUser(payload), + onSuccess: async (response) => { + toast(message(`Impersonating User "${response.user.display_name}"`)); + window.location.href = "/"; + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function impersonateUser(payload) { + return apiClient.post(`admin/users/impersonate/${payload.userId}`, payload).then((r) => r.data); +} +const userDatatableColumns = [ + { + key: "name", + allowsSorting: true, + sortingKey: "email", + width: "flex-3 min-w-200", + visibleInMode: "all", + header: () => /* @__PURE__ */ jsx(Trans, { message: "User" }), + body: (user) => /* @__PURE__ */ jsx( + NameWithAvatar, + { + image: user.avatar, + label: user.display_name, + description: user.email + } + ) + }, + { + key: "subscribed", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Subscribed" }), + width: "w-96", + body: (user) => { + var _a2; + return ((_a2 = user.subscriptions) == null ? void 0 : _a2.length) ? /* @__PURE__ */ jsx(CheckIcon, { className: "text-positive icon-md" }) : /* @__PURE__ */ jsx(CloseIcon, { className: "text-danger icon-md" }); + } + }, + { + key: "roles", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Roles" }), + body: (user) => { + var _a2; + return /* @__PURE__ */ jsx(ChipList, { radius: "rounded", size: "xs", children: (_a2 = user == null ? void 0 : user.roles) == null ? void 0 : _a2.map((role) => /* @__PURE__ */ jsx(Chip, { selectable: true, children: /* @__PURE__ */ jsx( + Link, + { + className: clsx("capitalize"), + target: "_blank", + to: `/admin/roles/${role.id}/edit`, + children: /* @__PURE__ */ jsx(Trans, { message: role.name }) + } + ) }, role.id)) }); + } + }, + { + key: "firstName", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "First name" }), + body: (user) => user.first_name + }, + { + key: "lastName", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last name" }), + body: (user) => user.last_name + }, + { + key: "createdAt", + allowsSorting: true, + width: "w-96", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Created at" }), + body: (user) => /* @__PURE__ */ jsx("time", { children: /* @__PURE__ */ jsx(FormattedDate, { date: user.created_at }) }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + width: "w-128 flex-shrink-0", + hideHeader: true, + align: "end", + visibleInMode: "all", + body: (user) => /* @__PURE__ */ jsxs("div", { className: "text-muted", children: [ + /* @__PURE__ */ jsx(Link, { to: `${user.id}/edit`, children: /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Edit user" }), children: /* @__PURE__ */ jsx(IconButton, { size: "md", children: /* @__PURE__ */ jsx(EditIcon, {}) }) }) }), + user.banned_at ? /* @__PURE__ */ jsx(UnbanButton, { user }) : /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Suspend user" }), children: /* @__PURE__ */ jsx(IconButton, { size: "md", children: /* @__PURE__ */ jsx(PersonOffIcon, {}) }) }), + /* @__PURE__ */ jsx(BanUserDialog, { user }) + ] }), + /* @__PURE__ */ jsx(ImpersonateButton, { user }) + ] }) + } +]; +function UnbanButton({ user }) { + const unban = useUnbanUser(user.id); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (confirmed) => { + if (confirmed) { + unban.mutate(); + } + }, + children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Remove suspension" }), children: /* @__PURE__ */ jsx(IconButton, { size: "md", color: "danger", children: /* @__PURE__ */ jsx(PersonOffIcon, {}) }) }), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isDanger: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Suspend “:name“", values: { name: user.display_name } }), + body: /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to remove suspension from this user?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Unsuspend" }) + } + ) + ] + } + ); +} +function ImpersonateButton({ user }) { + const impersonate = useImpersonateUser(); + return /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Login as user" }), children: /* @__PURE__ */ jsx(IconButton, { size: "md", children: /* @__PURE__ */ jsx(LoginIcon, {}) }) }), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + title: /* @__PURE__ */ jsx( + Trans, + { + message: "Login as “:name“", + values: { name: user.display_name } + } + ), + isLoading: impersonate.isPending, + body: /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to login as this user?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Login" }), + onConfirm: () => { + impersonate.mutate({ userId: user.id }); + } + } + ) + ] }); +} +function UserDatatable() { + const { billing } = useSettings(); + const filteredColumns = !billing.enable ? userDatatableColumns.filter((c) => c.key !== "subscribed") : userDatatableColumns; + return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "users", + title: /* @__PURE__ */ jsx(Trans, { message: "Users" }), + filters: UserDatatableFilters, + columns: filteredColumns, + actions: /* @__PURE__ */ jsx(Actions$f, {}), + queryParams: { with: "subscriptions,bans" }, + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: teamSvg, + title: /* @__PURE__ */ jsx(Trans, { message: "No users have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching users" }) + } + ) + } + ) }); +} +function Actions$f() { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(DataTableExportCsvButton, { endpoint: "users/csv/export" }), + /* @__PURE__ */ jsx(DataTableAddItemButton, { elementType: Link, to: "new", children: /* @__PURE__ */ jsx(Trans, { message: "Add new user" }) }) + ] }); +} +function chunkArray(array, chunkSize) { + return array.reduce((resultArray, item, index) => { + const chunkIndex = Math.floor(index / chunkSize); + if (!resultArray[chunkIndex]) { + resultArray[chunkIndex] = []; + } + resultArray[chunkIndex].push(item); + return resultArray; + }, []); +} +const DefaultAppearanceConfig = { + preview: { + defaultRoute: "/", + navigationRoutes: [] + }, + sections: { + general: { + label: message("General"), + position: 1, + buildBreadcrumb: () => [ + { + label: message("General"), + location: `general` + } + ] + }, + themes: { + label: message("Themes"), + position: 2, + buildBreadcrumb: (pathname, formValue) => { + var _a2; + const parts = pathname.split("/").filter((p) => !!p); + const [, , , themeIndex] = parts; + const breadcrumb = [ + { + label: message("Themes"), + location: `themes` + } + ]; + if (themeIndex != null) { + breadcrumb.push({ + label: (_a2 = formValue.appearance.themes.all[+themeIndex]) == null ? void 0 : _a2.name, + location: `themes/${themeIndex}` + }); + } + if (parts.at(-1) === "font") { + breadcrumb.push({ + label: message("Font"), + location: `themes/${themeIndex}/font` + }); + } + if (parts.at(-1) === "radius") { + breadcrumb.push({ + label: message("Rounding"), + location: `themes/${themeIndex}/radius` + }); + } + return breadcrumb; + } + }, + menus: { + label: message("Menus"), + position: 3, + buildBreadcrumb: (pathname, formValue) => { + const parts = pathname.split("/").filter((p) => !!p); + const [, , ...rest] = parts; + const breadcrumb = [ + { + label: message("Menus"), + location: "menus" + } + ]; + const chunked = chunkArray(rest, 2); + chunked.forEach(([sectionName, sectionIndex], chunkIndex) => { + var _a2, _b; + if (sectionName === "menus" && sectionIndex != null) { + breadcrumb.push({ + label: (_a2 = formValue.settings.menus[+sectionIndex]) == null ? void 0 : _a2.name, + location: `menus/${sectionIndex}` + }); + } else if (sectionName === "items" && sectionIndex != null) { + const [, menuIndex] = chunked[chunkIndex - 1]; + breadcrumb.push({ + label: (_b = formValue.settings.menus[+menuIndex].items[+sectionIndex]) == null ? void 0 : _b.label, + location: `menus/${menuIndex}/${sectionIndex}` + }); + } + }); + return breadcrumb; + }, + config: { + availableRoutes: [ + "/", + "/login", + "/register", + "/contact", + "/pricing", + "/account-settings", + "/admin", + "/admin/appearance", + "/admin/settings", + "/admin/plans", + "/admin/subscriptions", + "/admin/users", + "/admin/roles", + "/admin/pages", + "/admin/tags", + "/admin/files", + "/admin/localizations", + "/admin/ads", + "/admin/settings/authentication", + "/admin/settings/branding", + "/admin/settings/cache", + "/admin/settings/providers", + "/api-docs" + ], + positions: [ + "admin-navbar", + "admin-sidebar", + "custom-page-navbar", + "auth-page-footer", + "auth-dropdown", + "account-settings-page", + "billing-page", + "checkout-page-navbar", + "checkout-page-footer", + "pricing-table-page", + "contact-us-page", + "notifications-page", + "footer", + "footer-secondary" + ] + } + }, + "custom-code": { + label: message("Custom Code"), + position: 4, + buildBreadcrumb: () => [ + { + label: message("Custom code"), + location: `custom-code` + } + ] + }, + "seo-settings": { + label: message("SEO Settings"), + position: 5, + buildBreadcrumb: () => [ + { + label: message("SEO"), + location: `seo` + } + ] + } + } +}; +const AppearanceButton = forwardRef( + ({ startIcon, children, className, description, ...other }, ref) => { + return /* @__PURE__ */ jsxs( + ButtonBase, + { + ref, + display: "flex", + className: clsx( + "relative mb-10 h-54 w-full items-center gap-10 rounded-input border bg px-14 text-sm hover:bg-hover", + className + ), + variant: null, + ...other, + children: [ + startIcon, + /* @__PURE__ */ jsxs("span", { className: "block min-w-0", children: [ + /* @__PURE__ */ jsx("span", { className: "block", children }), + description && /* @__PURE__ */ jsx("span", { className: "block overflow-hidden overflow-ellipsis whitespace-nowrap text-xs text-muted", children: description }) + ] }), + /* @__PURE__ */ jsx( + KeyboardArrowRightIcon, + { + "aria-hidden": true, + className: "ml-auto text-muted icon-sm" + } + ) + ] + } + ); + } +); +const ColorIcon = createSvgIcon( + /* @__PURE__ */ jsx( + "path", + { + stroke: "#E0E0E0", + d: "M24,44c-7.168,0-13-5.816-13-12.971C11,24,24,4,24,4s13,20,13,27.029C37,38.184,31.168,44,24,44z" + } + ) +); +function ColorSwatch({ onChange, value, colors }) { + const presetButtons = colors.map((color) => { + const isSelected = value === color; + return /* @__PURE__ */ jsx( + ButtonBase, + { + onClick: () => { + onChange == null ? void 0 : onChange(color); + }, + className: clsx( + "relative block flex-shrink-0 w-26 h-26 border rounded", + isSelected && "shadow-md" + ), + style: { backgroundColor: color }, + children: isSelected && /* @__PURE__ */ jsx("span", { className: "absolute inset-0 m-auto rounded-full w-8 h-8 bg-white" }) + }, + color + ); + }); + return /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-6", children: presetButtons }); +} +const ColorPresets = [ + { + color: "rgb(255, 255, 255)", + name: message("White") + }, + { + color: "rgb(239,245,245)", + name: message("Solitude") + }, + { + color: "rgb(245,213,174)", + name: message("Wheat") + }, + { + color: "rgb(253,227,167)", + name: message("Cape Honey") + }, + { + color: "rgb(242,222,186)", + name: message("Milk punch") + }, + { + color: "rgb(97,118,75)", + name: message("Dingy"), + foreground: "rgb(255, 255, 255)" + }, + { + color: "rgb(4, 147, 114)", + name: message("Aquamarine"), + foreground: "rgb(255, 255, 255)" + }, + { + color: "rgb(222,245,229)", + name: message("Cosmic Latte") + }, + { + color: "rgb(233,119,119)", + name: message("Geraldine"), + foreground: "rgb(90,14,14)" + }, + { + color: "rgb(247,164,164)", + name: message("Sundown") + }, + { + color: "rgb(30,139,195)", + name: message("Pelorous"), + foreground: "rgb(255, 255, 255)" + }, + { + color: "rgb(142,68,173)", + name: message("Deep Lilac"), + foreground: "rgb(255, 255, 255)" + }, + { + color: "rgb(108,74,182)", + name: message("Blue marguerite"), + foreground: "rgb(255, 255, 255)" + }, + { + color: "rgb(139,126,116)", + name: message("Americano"), + foreground: "rgb(255, 255, 255)" + }, + { + color: "rgb(0,0,0)", + name: message("Black"), + foreground: "rgb(255, 255, 255)" + }, + { + color: "rgb(64,66,88)", + name: message("Blue zodiac"), + foreground: "rgb(255, 255, 255)" + }, + { + color: "rgb(101,100,124)", + name: message("Comet"), + foreground: "rgb(255, 255, 255)" + } +]; +const DefaultPresets = ColorPresets.map(({ color }) => color).slice(0, 14); +function ColorPicker({ + defaultValue, + onChange, + colorPresets, + showInput +}) { + const [color, setColor] = useState(defaultValue); + const presets = colorPresets || DefaultPresets; + const style = getInputFieldClassNames({ size: "sm" }); + return /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx( + HexColorPicker, + { + className: "!w-auto", + color, + onChange: (newColor) => { + onChange == null ? void 0 : onChange(newColor); + setColor(newColor); + } + } + ), + /* @__PURE__ */ jsxs("div", { className: "py-20 px-12", children: [ + presets && /* @__PURE__ */ jsx( + ColorSwatch, + { + colors: presets, + onChange: (newColor) => { + if (newColor) { + const hex = parseColor(newColor).toString("hex"); + onChange == null ? void 0 : onChange(hex); + setColor(hex); + } + }, + value: color + } + ), + showInput && /* @__PURE__ */ jsx("div", { className: "pt-20", children: /* @__PURE__ */ jsx( + HexColorInput, + { + autoComplete: "off", + role: "textbox", + autoCorrect: "off", + spellCheck: "false", + required: true, + "aria-label": "Hex color", + prefixed: true, + className: style.input, + color, + onChange: (newColor) => { + onChange == null ? void 0 : onChange(newColor); + setColor(newColor); + } + } + ) }) + ] }) + ] }); +} +function ColorPickerDialog({ + hideFooter = false, + showInput = true +}) { + const { close, value, setValue, initialValue } = useDialogContext(); + return /* @__PURE__ */ jsxs(Dialog, { size: "2xs", children: [ + /* @__PURE__ */ jsx( + ColorPicker, + { + showInput, + defaultValue: initialValue ? initialValue : "", + onChange: (newValue) => setValue(newValue) + } + ), + !hideFooter && /* @__PURE__ */ jsxs(DialogFooter, { dividerTop: true, children: [ + /* @__PURE__ */ jsx(Button, { variant: "text", size: "xs", onClick: () => close(), children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + size: "xs", + onClick: () => close(value), + children: /* @__PURE__ */ jsx(Trans, { message: "Apply" }) + } + ) + ] }) + ] }); +} +function LandingPageSectionGeneral() { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(HeaderSection, {}), + /* @__PURE__ */ jsxs("div", { className: "my-24 border-y py-24", children: [ + /* @__PURE__ */ jsx( + AppearanceButton, + { + to: "action-buttons", + elementType: Link, + className: "mb-20", + children: /* @__PURE__ */ jsx(Trans, { message: "Action buttons" }) + } + ), + /* @__PURE__ */ jsx(AppearanceButton, { to: "primary-features", elementType: Link, children: /* @__PURE__ */ jsx(Trans, { message: "Primary features" }) }), + /* @__PURE__ */ jsx(AppearanceButton, { to: "secondary-features", elementType: Link, children: /* @__PURE__ */ jsx(Trans, { message: "Secondary features" }) }) + ] }), + /* @__PURE__ */ jsx(FooterSection, {}), + /* @__PURE__ */ jsx(PricingSection, {}) + ] }); +} +function HeaderSection() { + const defaultImage = useAppearanceStore( + (s) => { + var _a2, _b, _c; + return (_c = (_b = (_a2 = s.defaults) == null ? void 0 : _a2.settings.homepage) == null ? void 0 : _b.appearance) == null ? void 0 : _c.headerImage; + } + ); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Header title" }), + className: "mb-20", + name: "settings.homepage.appearance.headerTitle", + onFocus: () => { + appearanceState().preview.setHighlight('[data-testid="headerTitle"]'); + } + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Header subtitle" }), + className: "mb-30", + inputElementType: "textarea", + rows: 4, + name: "settings.homepage.appearance.headerSubtitle", + onFocus: () => { + appearanceState().preview.setHighlight( + '[data-testid="headerSubtitle"]' + ); + } + } + ), + /* @__PURE__ */ jsx( + FormImageSelector, + { + name: "settings.homepage.appearance.headerImage", + className: "mb-30", + label: /* @__PURE__ */ jsx(Trans, { message: "Header image" }), + defaultValue: defaultImage, + diskPrefix: "homepage" + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-24", + name: "settings.homepage.appearance.blurHeaderImage", + children: /* @__PURE__ */ jsx(Trans, { message: "Blur header image" }) + } + ), + /* @__PURE__ */ jsx( + FormSlider, + { + name: "settings.homepage.appearance.headerImageOpacity", + label: /* @__PURE__ */ jsx(Trans, { message: "Header image opacity" }), + minValue: 0, + step: 0.1, + maxValue: 1, + formatOptions: { style: "percent" } + } + ), + /* @__PURE__ */ jsx("div", { className: "mb-20 text-xs text-muted", children: /* @__PURE__ */ jsx(Trans, { message: "In order for overlay colors to appear, header image opacity will need to be less then 100%" }) }), + /* @__PURE__ */ jsx( + ColorPickerTrigger$1, + { + formKey: "settings.homepage.appearance.headerOverlayColor1", + label: /* @__PURE__ */ jsx(Trans, { message: "Header overlay color 1" }) + } + ), + /* @__PURE__ */ jsx( + ColorPickerTrigger$1, + { + formKey: "settings.homepage.appearance.headerOverlayColor2", + label: /* @__PURE__ */ jsx(Trans, { message: "Header overlay color 2" }) + } + ) + ] }); +} +function FooterSection() { + const defaultImage = useAppearanceStore( + (s) => { + var _a2, _b, _c; + return (_c = (_b = (_a2 = s.defaults) == null ? void 0 : _a2.settings.homepage) == null ? void 0 : _b.appearance) == null ? void 0 : _c.footerImage; + } + ); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(FormSwitch, { className: "mb-24", name: "settings.homepage.trending", children: /* @__PURE__ */ jsx(Trans, { message: "Show trending titles" }) }), + /* @__PURE__ */ jsx( + FormTextField, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Footer title" }), + className: "mb-20", + name: "settings.homepage.appearance.footerTitle", + onFocus: () => { + appearanceState().preview.setHighlight('[data-testid="footerTitle"]'); + } + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Footer subtitle" }), + className: "mb-20", + name: "settings.homepage.appearance.footerSubtitle", + onFocus: () => { + appearanceState().preview.setHighlight( + '[data-testid="footerSubtitle"]' + ); + } + } + ), + /* @__PURE__ */ jsx( + FormImageSelector, + { + name: "settings.homepage.appearance.footerImage", + className: "mb-30", + label: /* @__PURE__ */ jsx(Trans, { message: "Footer background image" }), + defaultValue: defaultImage, + diskPrefix: "homepage" + } + ) + ] }); +} +function PricingSection() { + return /* @__PURE__ */ jsxs("div", { className: "mt-24 border-t pt-24", children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Pricing title" }), + className: "mb-20", + name: "settings.homepage.appearance.pricingTitle", + onFocus: () => { + appearanceState().preview.setHighlight( + '[data-testid="pricingTitle"]' + ); + } + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Pricing subtitle" }), + className: "mb-20", + name: "settings.homepage.appearance.pricingSubtitle", + onFocus: () => { + appearanceState().preview.setHighlight( + '[data-testid="pricingSubtitle"]' + ); + } + } + ), + /* @__PURE__ */ jsx(FormSwitch, { className: "mb-24", name: "settings.homepage.pricing", children: /* @__PURE__ */ jsx(Trans, { message: "Show pricing table" }) }) + ] }); +} +function ColorPickerTrigger$1({ label, formKey }) { + const key = formKey; + const { watch, setValue } = useFormContext(); + const formValue = watch(key); + const setColor = (value) => { + setValue(formKey, value, { + shouldDirty: true + }); + }; + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + value: formValue, + onValueChange: (newValue) => setColor(newValue), + type: "popover", + onClose: (value) => setColor(value), + children: [ + /* @__PURE__ */ jsx( + AppearanceButton, + { + className: "capitalize", + startIcon: /* @__PURE__ */ jsx( + ColorIcon, + { + viewBox: "0 0 48 48", + className: "icon-lg", + style: { fill: formValue } + } + ), + children: label + } + ), + /* @__PURE__ */ jsx(ColorPickerDialog, {}) + ] + } + ); +} +function ucFirst(string) { + if (!string) + return string; + return string.charAt(0).toUpperCase() + string.slice(1); +} +const PermissionSelector$1 = React.forwardRef(({ valueListKey = "permissions", ...props }, ref) => { + const { data } = useValueLists([valueListKey]); + const permissions = (data == null ? void 0 : data.permissions) || (data == null ? void 0 : data.workspacePermissions); + const [value, setValue] = useControlledState(props.value, [], props.onChange); + const [showAdvanced, setShowAdvanced] = useState(false); + if (!permissions) + return null; + const groupedPermissions = buildPermissionList( + permissions, + value, + showAdvanced + ); + const onRestrictionChange = (newPermission) => { + const newValue = [...value]; + const index = newValue.findIndex((p) => p.id === newPermission.id); + if (index > -1) { + newValue.splice(index, 1, newPermission); + } + setValue(newValue); + }; + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(Accordion, { variant: "outline", ref, children: groupedPermissions.map(({ groupName, items, anyChecked }) => /* @__PURE__ */ jsx( + AccordionItem, + { + label: /* @__PURE__ */ jsx(Trans, { message: prettyName(groupName) }), + startIcon: anyChecked ? /* @__PURE__ */ jsx(DoneAllIcon, { size: "sm" }) : void 0, + children: /* @__PURE__ */ jsx(List, { children: items.map((permission) => { + const index = value.findIndex((v) => v.id === permission.id); + const isChecked = index > -1; + return /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx( + ListItem, + { + onSelected: () => { + if (isChecked) { + const newValue = [...value]; + newValue.splice(index, 1); + setValue(newValue); + } else { + setValue([...value, permission]); + } + }, + endSection: /* @__PURE__ */ jsx( + Switch, + { + tabIndex: -1, + checked: isChecked, + onChange: () => { + } + } + ), + description: /* @__PURE__ */ jsx(Trans, { message: permission.description }), + children: /* @__PURE__ */ jsx( + Trans, + { + message: permission.display_name || permission.name + } + ) + } + ), + isChecked && /* @__PURE__ */ jsx( + Restrictions, + { + permission, + onChange: onRestrictionChange + } + ) + ] }, permission.id); + }) }) + }, + groupName + )) }), + /* @__PURE__ */ jsx( + Switch, + { + className: "mt-30", + checked: showAdvanced, + onChange: (e) => { + setShowAdvanced(e.target.checked); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Show advanced permissions" }) + } + ) + ] }); +}); +function Restrictions({ permission, onChange }) { + var _a2; + if (!((_a2 = permission == null ? void 0 : permission.restrictions) == null ? void 0 : _a2.length)) + return null; + const setRestrictionValue = (name, value) => { + const nextState = produce(permission, (draftState) => { + const restriction = draftState.restrictions.find((r) => r.name === name); + if (restriction) { + restriction.value = value; + } + }); + onChange == null ? void 0 : onChange(nextState); + }; + return /* @__PURE__ */ jsx("div", { className: "px-40 py-20", children: permission.restrictions.map((restriction, index) => { + const isLast = index === permission.restrictions.length - 1; + const name = /* @__PURE__ */ jsx(Trans, { message: prettyName(restriction.name) }); + const description = restriction.description ? /* @__PURE__ */ jsx(Trans, { message: restriction.description }) : void 0; + if (restriction.type === "bool") { + return /* @__PURE__ */ jsx( + Switch, + { + description, + className: clsx(!isLast && "mb-30"), + checked: Boolean(restriction.value), + onChange: (e) => { + setRestrictionValue(restriction.name, e.target.checked); + }, + children: name + }, + restriction.name + ); + } + return /* @__PURE__ */ jsx( + TextField, + { + size: "sm", + label: name, + description, + type: "number", + className: clsx(!isLast && "mb-30"), + value: restriction.value || "", + onChange: (e) => { + setRestrictionValue( + restriction.name, + e.target.value === "" ? void 0 : parseInt(e.target.value) + ); + } + }, + restriction.name + ); + }) }); +} +function FormPermissionSelector(props) { + const { + field: { onChange, value = [], ref } + } = useController({ + name: props.name + }); + const formProps = { + onChange, + value + }; + return /* @__PURE__ */ jsx(PermissionSelector$1, { ref, ...mergeProps(formProps, props) }); +} +const prettyName = (name) => { + return ucFirst(name.replace("_", " ")); +}; +function buildPermissionList(allPermissions, selectedPermissions, showAdvanced) { + const groupedPermissions = []; + allPermissions.forEach((permission) => { + const index = selectedPermissions.findIndex((p) => p.id === permission.id); + if (!showAdvanced && permission.advanced) + return; + let group = groupedPermissions.find( + (g) => g.groupName === permission.group + ); + if (!group) { + group = { groupName: permission.group, anyChecked: false, items: [] }; + groupedPermissions.push(group); + } + if (index > -1) { + const mergedPermission = { + ...permission, + restrictions: mergeRestrictions( + permission.restrictions, + selectedPermissions[index].restrictions + ) + }; + group.anyChecked = true; + group.items.push(mergedPermission); + } else { + group.items.push(permission); + } + }); + return groupedPermissions; +} +function mergeRestrictions(allRestrictions, selectedRestrictions) { + return allRestrictions == null ? void 0 : allRestrictions.map((restriction) => { + const selected = selectedRestrictions.find( + (r) => r.name === restriction.name + ); + if (selected) { + return { ...restriction, value: selected.value }; + } else { + return restriction; + } + }); +} +function useAvailableRoutes() { + const menuConfig = mergedAppearanceConfig.sections.menus.config; + if (!menuConfig) + return []; + return menuConfig.availableRoutes.map((route) => { + return { + id: route, + label: route, + action: route, + type: "route", + target: "_self" + }; + }); +} +const iconGridStyle = { + grid: "flex flex-wrap gap-24", + button: "flex flex-col items-center rounded hover:bg-hover h-90 aspect-square" +}; +const skeletons = [...Array(60).keys()]; +const IconList = React.lazy(() => import("./icon-list-0919dd48.mjs")); +function IconPicker({ onIconSelected }) { + const { trans } = useTrans(); + const [value, setValue] = React.useState(""); + return /* @__PURE__ */ jsxs("div", { className: "py-4", children: [ + /* @__PURE__ */ jsx( + TextField, + { + className: "mb-20", + value, + onChange: (e) => { + setValue(e.target.value); + }, + placeholder: trans({ message: "Search icons..." }) + } + ), + /* @__PURE__ */ jsx(AnimatePresence, { mode: "wait", children: /* @__PURE__ */ jsx( + Suspense, + { + fallback: /* @__PURE__ */ jsx(m.div, { ...opacityAnimation, className: iconGridStyle.grid, children: skeletons.map((_, index) => /* @__PURE__ */ jsx("div", { className: iconGridStyle.button, children: /* @__PURE__ */ jsx(Skeleton, { variant: "rect" }) }, index)) }), + children: /* @__PURE__ */ jsx(m.div, { ...opacityAnimation, className: iconGridStyle.grid, children: /* @__PURE__ */ jsx(IconList, { searchQuery: value, onIconSelected }) }) + } + ) }) + ] }); +} +function IconPickerDialog() { + return /* @__PURE__ */ jsxs(Dialog, { size: "w-850", className: "min-h-dialog", children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Select icon" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsx(IconPickerWrapper, {}) }) + ] }); +} +function IconPickerWrapper() { + const { close } = useDialogContext(); + return /* @__PURE__ */ jsx( + IconPicker, + { + onIconSelected: (value) => { + close(value); + } + } + ); +} +function MenuItemForm({ + formPathPrefix, + hideRoleAndPermissionFields +}) { + const { trans } = useTrans(); + const prefixName = (name) => { + return formPathPrefix ? `${formPathPrefix}.${name}` : name; + }; + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-20", + name: prefixName("label"), + label: /* @__PURE__ */ jsx(Trans, { message: "Label" }), + placeholder: trans(message("No label...")), + startAppend: /* @__PURE__ */ jsx(IconDialogTrigger, { prefixName }) + } + ), + /* @__PURE__ */ jsx(DestinationSelector, { prefixName }), + !hideRoleAndPermissionFields && /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(RoleSelector, { prefixName }), + /* @__PURE__ */ jsx(PermissionSelector, { prefixName }) + ] }), + /* @__PURE__ */ jsx(TargetSelect, { prefixName }) + ] }); +} +function IconDialogTrigger({ + prefixName, + ...buttonProps +}) { + const { watch, setValue } = useFormContext(); + const fieldName = prefixName("icon"); + const watchedItemIcon = watch(fieldName); + const Icon = watchedItemIcon && createSvgIconFromTree(watchedItemIcon); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (iconTree) => { + if (iconTree || iconTree === null) { + setValue(fieldName, iconTree, { + shouldDirty: true + }); + } + }, + children: [ + /* @__PURE__ */ jsx( + IconButton, + { + className: "text-muted icon-sm", + variant: "outline", + size: "md", + ...buttonProps, + children: Icon ? /* @__PURE__ */ jsx(Icon, {}) : /* @__PURE__ */ jsx(EditIcon, {}) + } + ), + /* @__PURE__ */ jsx(IconPickerDialog, {}) + ] + } + ); +} +function DestinationSelector({ prefixName }) { + const form = useFormContext(); + const currentType = form.watch(prefixName("type")); + const previousType = usePrevious(currentType); + const { data } = useValueLists(["menuItemCategories"]); + const categories = (data == null ? void 0 : data.menuItemCategories) || []; + const selectedCategory = categories.find((c) => c.type === currentType); + const { trans } = useTrans(); + const routeItems = useAvailableRoutes(); + useEffect(() => { + if (previousType && previousType !== currentType) { + form.setValue(prefixName("action"), ""); + } + }, [currentType, previousType, form, prefixName]); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs( + FormSelect, + { + className: "mb-20", + name: prefixName("type"), + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Type" }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "link", children: /* @__PURE__ */ jsx(Trans, { message: "Custom link" }) }), + /* @__PURE__ */ jsx(Item, { value: "route", children: /* @__PURE__ */ jsx(Trans, { message: "Site page" }) }), + categories.map((category) => /* @__PURE__ */ jsx(Item, { value: category.type, children: category.name }, category.type)) + ] + } + ), + currentType === "link" && /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-20", + required: true, + type: "url", + name: prefixName("action"), + placeholder: trans({ message: "Enter a url..." }), + label: /* @__PURE__ */ jsx(Trans, { message: "Url" }) + } + ), + currentType === "route" && /* @__PURE__ */ jsx( + FormSelect, + { + className: "mb-20", + required: true, + items: routeItems, + name: prefixName("action"), + label: /* @__PURE__ */ jsx(Trans, { message: "Page" }), + searchPlaceholder: trans(message("Search pages")), + showSearchField: true, + selectionMode: "single", + children: (item) => /* @__PURE__ */ jsx(Item, { value: item.id, children: item.label }, item.id) + } + ), + selectedCategory && /* @__PURE__ */ jsx( + FormSelect, + { + className: "mb-20", + required: true, + items: selectedCategory.items, + name: prefixName("action"), + showSearchField: true, + searchPlaceholder: trans(message("Search...")), + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: selectedCategory.name }), + children: (item) => /* @__PURE__ */ jsx(Item, { value: item.action, children: /* @__PURE__ */ jsx(Trans, { message: item.label }) }) + } + ) + ] }); +} +function RoleSelector({ prefixName }) { + const { data } = useValueLists(["roles", "permissions"]); + const roles = (data == null ? void 0 : data.roles) || []; + const { trans } = useTrans(); + return /* @__PURE__ */ jsx( + FormChipField, + { + className: "mb-20", + placeholder: trans({ message: "Add role..." }), + label: /* @__PURE__ */ jsx(Trans, { message: "Only show if user has role" }), + name: prefixName("roles"), + chipSize: "sm", + suggestions: roles, + valueKey: "id", + displayWith: (c) => { + var _a2; + return (_a2 = roles.find((r) => r.id === c.id)) == null ? void 0 : _a2.name; + }, + children: (role) => /* @__PURE__ */ jsx(Item, { value: role.id, capitalizeFirst: true, children: /* @__PURE__ */ jsx(Trans, { message: role.name }) }, role.id) + } + ); +} +function PermissionSelector({ prefixName }) { + const { data } = useValueLists(["roles", "permissions"]); + const { trans } = useTrans(); + const groupedPermissions = useMemo(() => { + return buildPermissionList((data == null ? void 0 : data.permissions) || [], [], false); + }, [data == null ? void 0 : data.permissions]); + return /* @__PURE__ */ jsx( + FormChipField, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Only show if user has permissions" }), + placeholder: trans({ message: "Add permission..." }), + chipSize: "sm", + suggestions: groupedPermissions, + name: prefixName("permissions"), + valueKey: "name", + children: ({ groupName, items }) => /* @__PURE__ */ jsx(Section, { label: prettyName(groupName), children: items.map((permission) => /* @__PURE__ */ jsx( + Item, + { + value: permission.name, + description: /* @__PURE__ */ jsx(Trans, { message: permission.description }), + children: /* @__PURE__ */ jsx(Trans, { message: permission.display_name || permission.name }) + }, + permission.name + )) }, groupName) + } + ); +} +function TargetSelect({ prefixName }) { + return /* @__PURE__ */ jsxs( + FormSelect, + { + className: "mt-20", + selectionMode: "single", + name: prefixName("target"), + label: /* @__PURE__ */ jsx(Trans, { message: "Open link in" }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "_self", children: /* @__PURE__ */ jsx(Trans, { message: "Same window" }) }), + /* @__PURE__ */ jsx(Item, { value: "_blank", children: /* @__PURE__ */ jsx(Trans, { message: "New window" }) }) + ] + } + ); +} +function LandingPageSectionActionButtons() { + const [expandedValues, setExpandedValues] = useState(["cta1"]); + return /* @__PURE__ */ jsxs( + Accordion, + { + variant: "outline", + expandedValues, + onExpandedChange: (values) => { + setExpandedValues(values); + if (values.length) { + appearanceState().preview.setHighlight( + `[data-testid="${values[0]}"]` + ); + } + }, + children: [ + /* @__PURE__ */ jsx(AccordionItem, { value: "cta1", label: /* @__PURE__ */ jsx(Trans, { message: "Header button 1" }), children: /* @__PURE__ */ jsx(MenuItemForm, { formPathPrefix: "settings.homepage.appearance.actions.cta1" }) }), + /* @__PURE__ */ jsx(AccordionItem, { value: "ct2", label: /* @__PURE__ */ jsx(Trans, { message: "Header button 2" }), children: /* @__PURE__ */ jsx(MenuItemForm, { formPathPrefix: "settings.homepage.appearance.actions.cta2" }) }), + /* @__PURE__ */ jsx(AccordionItem, { value: "cta3", label: /* @__PURE__ */ jsx(Trans, { message: "Footer button" }), children: /* @__PURE__ */ jsx(MenuItemForm, { formPathPrefix: "settings.homepage.appearance.actions.cta3" }) }) + ] + } + ); +} +function LandingPageSectionPrimaryFeatures() { + const { fields, remove, append } = useFieldArray({ + name: "settings.homepage.appearance.primaryFeatures" + }); + const [expandedValues, setExpandedValues] = useState([0]); + return /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx( + Accordion, + { + variant: "outline", + expandedValues, + onExpandedChange: (values) => { + setExpandedValues(values); + if (values.length) { + appearanceState().preview.setHighlight( + `[data-testid="primary-root-${values[0]}"]` + ); + } + }, + children: fields.map((field, index) => { + return /* @__PURE__ */ jsxs( + AccordionItem, + { + value: index, + label: /* @__PURE__ */ jsx(Trans, { message: `Primary feature ${index + 1}` }), + children: [ + /* @__PURE__ */ jsx(FeatureForm$1, { index }), + /* @__PURE__ */ jsx("div", { className: "text-right", children: /* @__PURE__ */ jsx( + Button, + { + size: "xs", + variant: "outline", + color: "danger", + onClick: () => { + remove(index); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Remove" }) + } + ) }) + ] + }, + field.id + ); + }) + } + ), + /* @__PURE__ */ jsx("div", { className: "mt-20 text-right", children: /* @__PURE__ */ jsx( + Button, + { + size: "xs", + variant: "outline", + color: "primary", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + onClick: () => { + append({}); + setExpandedValues([fields.length]); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Add feature" }) + } + ) }) + ] }); +} +function FeatureForm$1({ index }) { + const defaultImage = useAppearanceStore( + (s) => { + var _a2, _b, _c, _d, _e; + return (_e = (_d = (_c = (_b = (_a2 = s.defaults) == null ? void 0 : _a2.settings.homepage) == null ? void 0 : _b.appearance) == null ? void 0 : _c.primaryFeatures) == null ? void 0 : _d[index]) == null ? void 0 : _e.image; + } + ); + return /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormImageSelector, + { + name: `settings.homepage.appearance.primaryFeatures.${index}.image`, + className: "mb-30", + label: /* @__PURE__ */ jsx(Trans, { message: "Image" }), + defaultValue: defaultImage, + diskPrefix: "homepage" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: `settings.homepage.appearance.primaryFeatures.${index}.title`, + label: /* @__PURE__ */ jsx(Trans, { message: "Title" }), + className: "mb-20", + onFocus: () => { + appearanceState().preview.setHighlight( + `[data-testid="primary-title-${index}"]` + ); + } + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: `settings.homepage.appearance.primaryFeatures.${index}.subtitle`, + label: /* @__PURE__ */ jsx(Trans, { message: "Subtitle" }), + className: "mb-20", + inputElementType: "textarea", + rows: 4, + onFocus: () => { + appearanceState().preview.setHighlight( + `[data-testid="primary-subtitle-${index}"]` + ); + } + } + ) + ] }); +} +function LandingPageSecondaryFeatures() { + const { fields, remove, append } = useFieldArray({ + name: "settings.homepage.appearance.secondaryFeatures" + }); + const [expandedValues, setExpandedValues] = useState([0]); + return /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx( + Accordion, + { + variant: "outline", + expandedValues, + onExpandedChange: (values) => { + setExpandedValues(values); + if (values.length) { + appearanceState().preview.setHighlight( + `[data-testid="secondary-root-${values[0]}"]` + ); + } + }, + children: fields.map((field, index) => { + return /* @__PURE__ */ jsxs( + AccordionItem, + { + value: index, + label: /* @__PURE__ */ jsx(Trans, { message: `Secondary feature ${index + 1}` }), + children: [ + /* @__PURE__ */ jsx(FeatureForm, { index }), + /* @__PURE__ */ jsx("div", { className: "text-right", children: /* @__PURE__ */ jsx( + Button, + { + size: "xs", + variant: "outline", + color: "danger", + onClick: () => { + remove(index); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Remove" }) + } + ) }) + ] + }, + field.id + ); + }) + } + ), + /* @__PURE__ */ jsx("div", { className: "mt-20 text-right", children: /* @__PURE__ */ jsx( + Button, + { + size: "xs", + variant: "outline", + color: "primary", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + onClick: () => { + append({}); + setExpandedValues([fields.length]); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Add feature" }) + } + ) }) + ] }); +} +function FeatureForm({ index }) { + return /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormImageSelector, + { + name: `settings.homepage.appearance.secondaryFeatures.${index}.image`, + className: "mb-30", + label: /* @__PURE__ */ jsx(Trans, { message: "Image" }), + defaultValue: getDefaultImage(index), + diskPrefix: "homepage" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: `settings.homepage.appearance.secondaryFeatures.${index}.title`, + label: /* @__PURE__ */ jsx(Trans, { message: "Title" }), + className: "mb-20", + onFocus: () => { + appearanceState().preview.setHighlight( + `[data-testid="secondary-title-${index}"]` + ); + } + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: `settings.homepage.appearance.secondaryFeatures.${index}.subtitle`, + label: /* @__PURE__ */ jsx(Trans, { message: "Subtitle" }), + className: "mb-20", + inputElementType: "textarea", + rows: 4, + onFocus: () => { + appearanceState().preview.setHighlight( + `[data-testid="secondary-subtitle-${index}"]` + ); + } + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: `settings.homepage.appearance.secondaryFeatures.${index}.description`, + label: /* @__PURE__ */ jsx(Trans, { message: "Description" }), + className: "mb-20", + inputElementType: "textarea", + rows: 4, + onFocus: () => { + appearanceState().preview.setHighlight( + `[data-testid="secondary-description-${index}"]` + ); + } + } + ) + ] }); +} +function getDefaultImage(index) { + var _a2, _b, _c; + return (_c = (_b = (_a2 = appearanceState().defaults) == null ? void 0 : _a2.settings.homepage) == null ? void 0 : _b.appearance.secondaryFeatures[index]) == null ? void 0 : _c.image; +} +const AppAppearanceConfig = { + preview: { + defaultRoute: "dashboard", + navigationRoutes: ["dashboard"] + }, + sections: { + "landing-page": { + label: message("Landing Page"), + position: 1, + previewRoute: "/", + routes: [ + { path: "landing-page", element: /* @__PURE__ */ jsx(LandingPageSectionGeneral, {}) }, + { + path: "landing-page/action-buttons", + element: /* @__PURE__ */ jsx(LandingPageSectionActionButtons, {}) + }, + { + path: "landing-page/primary-features", + element: /* @__PURE__ */ jsx(LandingPageSectionPrimaryFeatures, {}) + }, + { + path: "landing-page/secondary-features", + element: /* @__PURE__ */ jsx(LandingPageSecondaryFeatures, {}) + } + ], + buildBreadcrumb: (pathname) => { + const parts = pathname.split("/").filter((p) => !!p); + const sectionName = parts.pop(); + const breadcrumb = [ + { + label: message("Landing page"), + location: "landing-page" + } + ]; + if (sectionName === "action-buttons") { + breadcrumb.push({ + label: message("Action buttons"), + location: "landing-page/action-buttons" + }); + } + if (sectionName === "primary-features") { + breadcrumb.push({ + label: message("Primary features"), + location: "landing-page/primary-features" + }); + } + if (sectionName === "secondary-features") { + breadcrumb.push({ + label: message("Secondary features"), + location: "landing-page/secondary-features" + }); + } + return breadcrumb; + } + }, + // missing label will get added by deepMerge from default config + // @ts-ignore + menus: { + config: { + positions: [ + "sidebar-primary", + "sidebar-secondary", + "mobile-bottom", + "landing-page-navbar", + "landing-page-footer" + ], + availableRoutes: [ + "/lists", + "/watchlist", + "/admin/channels", + "/admin/comments" + ] + } + }, + // @ts-ignore + "seo-settings": { + config: { + pages: [ + { + key: "title-page", + label: message("Title page") + }, + { + key: "season-page", + label: message("Season page") + }, + { + key: "episode-page", + label: message("Episode page") + }, + { + key: "watch-page", + label: message("Watch page") + }, + { + key: "person-page", + label: message("Person page") + }, + { + key: "landing-page", + label: message("Landing page") + }, + { + key: "news-article-page", + label: message("News article page") + }, + { + key: "channel-page", + label: message("Channel page") + } + ] + } + } + } +}; +const mergedAppearanceConfig = deepMerge.all([ + DefaultAppearanceConfig, + AppAppearanceConfig +]); +const useAppearanceStore = create()( + subscribeWithSelector( + immer((set, get) => ({ + defaults: null, + iframeWindow: null, + config: mergedAppearanceConfig, + setDefaults: (value) => { + set((state) => { + state.defaults = { ...value }; + }); + }, + setIframeWindow: (value) => { + set(() => { + return { iframeWindow: value }; + }); + }, + preview: { + navigate: (sectionName) => { + var _a2; + const section = (_a2 = get().config) == null ? void 0 : _a2.sections[sectionName]; + const route = (section == null ? void 0 : section.previewRoute) || "/"; + const preview = get().iframeWindow; + if (route) { + postMessage(preview, { type: "navigate", to: route }); + } + }, + setValues: (values) => { + const preview = get().iframeWindow; + postMessage(preview, { type: "setValues", values }); + }, + setThemeFont: (font) => { + const preview = get().iframeWindow; + postMessage(preview, { type: "setThemeFont", value: font }); + }, + setThemeValue: (name, value) => { + const preview = get().iframeWindow; + postMessage(preview, { type: "setThemeValue", name, value }); + }, + setActiveTheme: (themeId) => { + const preview = get().iframeWindow; + postMessage(preview, { type: "setActiveTheme", themeId }); + }, + setCustomCode: (mode, value) => { + const preview = get().iframeWindow; + postMessage(preview, { type: "setCustomCode", mode, value }); + }, + setHighlight: (selector) => { + set(() => { + var _a2; + let node = null; + const document2 = (_a2 = get().iframeWindow) == null ? void 0 : _a2.document; + if (document2 && selector) { + node = document2.querySelector(selector); + } + if (node) { + requestAnimationFrame(() => { + if (!node) + return; + node.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center" + }); + }); + } + }); + } + } + })) + ) +); +function postMessage(window2, command) { + if (window2) { + window2.postMessage({ source: "be-appearance-editor", ...command }, "*"); + } +} +function appearanceState() { + return useAppearanceStore.getState(); +} +function useSaveAppearanceChanges() { + return useMutation({ + mutationFn: (values) => saveAppearanceChanges(values), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["admin/appearance/values"] + }); + toast(message("Changes saved")); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function saveAppearanceChanges(changes) { + return apiClient.post(`admin/appearance`, { changes }).then((r) => r.data); +} +function useAppearanceValues() { + return useQuery({ + queryKey: ["admin/appearance/values"], + queryFn: () => fetchAppearanceValues(), + staleTime: Infinity + }); +} +function fetchAppearanceValues() { + return apiClient.get("admin/appearance/values").then((response) => response.data); +} +function SectionHeader() { + const { pathname } = useLocation(); + const { getValues } = useFormContext(); + const [breadcrumb, setBreadcrumb] = useState(null); + useEffect(() => { + var _a2; + const [, , sectionName] = pathname.split("/").filter((p) => !!p); + if (sectionName) { + const section = (_a2 = appearanceState().config) == null ? void 0 : _a2.sections[sectionName]; + if (section) { + setBreadcrumb([ + { + label: message("Appearance"), + location: "" + }, + ...section.buildBreadcrumb(pathname, getValues()) + ]); + return; + } + } + setBreadcrumb(null); + }, [pathname, getValues]); + if (!breadcrumb || breadcrumb.length < 2) { + return null; + } + return /* @__PURE__ */ jsxs("div", { className: "flex items-center border-b h-60 flex-shrink-0", children: [ + /* @__PURE__ */ jsx( + IconButton, + { + iconSize: "md", + radius: "rounded-none", + className: "text-muted h-full w-50 flex-shrink-0", + elementType: Link, + to: `/admin/appearance/${breadcrumb[breadcrumb.length - 2].location}`, + children: /* @__PURE__ */ jsx(KeyboardArrowLeftIcon, {}) + } + ), + /* @__PURE__ */ jsxs("div", { className: "border-l p-10 min-w-0", children: [ + /* @__PURE__ */ jsx("div", { className: "text-xs text-muted", children: /* @__PURE__ */ jsx(Trans, { message: "Customizing" }) }), + /* @__PURE__ */ jsx("div", { className: "flex items-center gap-4 text-sm mt-2", children: breadcrumb.map((item, index) => { + const isLast = breadcrumb.length - 1 === index; + const isFirst = index === 0; + const label = /* @__PURE__ */ jsx(MixedText, { value: item.label }); + if (isFirst) { + return null; + } + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + "div", + { + className: clsx( + "whitespace-nowrap overflow-hidden overflow-ellipsis min-w-0", + isLast && "text-primary", + // don't overflow ellipses last item + isLast ? "flex-shrink-0" : "flex-auto" + ), + children: label + } + ), + !isLast && /* @__PURE__ */ jsx(KeyboardArrowRightIcon, { className: "icon-sm text-muted flex-shrink-0" }) + ] }, index); + }) }) + ] }) + ] }); +} +function AppearanceLayout() { + const { isAppearanceEditorActive } = useAppearanceEditorMode(); + const { data } = useAppearanceValues(); + const { base_url } = useSettings(); + const iframeRef = useRef(null); + const { pathname } = useLocation(); + useEffect(() => { + if ((data == null ? void 0 : data.defaults) && !appearanceState().defaults) { + appearanceState().setDefaults(data.defaults); + } + }, [data]); + useEffect(() => { + if (iframeRef.current) { + appearanceState().setIframeWindow(iframeRef.current.contentWindow); + } + }, []); + useEffect(() => { + const sectionName = pathname.split("/")[3]; + appearanceState().preview.navigate(sectionName); + }, [pathname]); + if (isAppearanceEditorActive) { + return /* @__PURE__ */ jsx(Navigate, { to: "/admin" }); + } + return /* @__PURE__ */ jsxs("div", { className: "h-screen items-center md:flex", children: [ + /* @__PURE__ */ jsx(StaticPageTitle, { children: /* @__PURE__ */ jsx(Trans, { message: "Appearance" }) }), + /* @__PURE__ */ jsx(Sidebar, { values: data == null ? void 0 : data.values }), + /* @__PURE__ */ jsx("div", { className: "relative h-full flex-auto", children: /* @__PURE__ */ jsx( + "iframe", + { + ref: iframeRef, + className: "h-full w-full max-md:hidden", + src: `${base_url}?appearanceEditor=true` + } + ) }) + ] }); +} +function Sidebar({ values }) { + const spinner = /* @__PURE__ */ jsx("div", { className: "flex h-full flex-auto items-center justify-center", children: /* @__PURE__ */ jsx(ProgressCircle, { isIndeterminate: true, "aria-label": "Loading editor" }) }); + return /* @__PURE__ */ jsx("div", { className: "relative z-10 h-full w-full border-r bg shadow-lg @container md:w-320", children: values ? /* @__PURE__ */ jsx(AppearanceForm, { defaultValues: values }) : spinner }); +} +function AppearanceForm({ defaultValues }) { + const form = useForm({ defaultValues }); + const { watch, reset } = form; + const saveChanges = useSaveAppearanceChanges(); + useEffect(() => { + const subscription = watch((value) => { + appearanceState().preview.setValues(value); + }); + return () => subscription.unsubscribe(); + }, [watch]); + return /* @__PURE__ */ jsxs( + Form$1, + { + className: "flex h-full flex-col", + form, + onSubmit: (values) => { + saveChanges.mutate(values, { + onSuccess: () => reset(values) + }); + }, + children: [ + /* @__PURE__ */ jsx(Header$4, { isLoading: saveChanges.isPending }), + /* @__PURE__ */ jsx(SectionHeader, {}), + /* @__PURE__ */ jsx("div", { className: "flex-auto overflow-y-auto px-14 py-20", children: /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsx(Outlet, {}) }) }) + ] + } + ); +} +function Header$4({ isLoading }) { + const { + formState: { dirtyFields } + } = useFormContext(); + const isDirty = Object.keys(dirtyFields).length; + return /* @__PURE__ */ jsxs("div", { className: "flex h-50 flex-shrink-0 items-center border-b pr-10", children: [ + /* @__PURE__ */ jsx( + IconButton, + { + border: "border-r", + className: "text-muted", + elementType: Link, + to: "..", + children: /* @__PURE__ */ jsx(CloseIcon, {}) + } + ), + /* @__PURE__ */ jsx("div", { className: "pl-10", children: /* @__PURE__ */ jsx(Trans, { message: "Appearance editor" }) }), + /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + className: "ml-auto block", + disabled: !isDirty || isLoading, + type: "submit", + children: isDirty ? /* @__PURE__ */ jsx(Trans, { message: "Save" }) : /* @__PURE__ */ jsx(Trans, { message: "Saved" }) + } + ) + ] }); +} +function MenuList() { + const navigate = useNavigate(); + const { trans } = useTrans(); + const { fields, append } = useFieldArray({ + name: "settings.menus", + keyName: "key" + }); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx("div", { children: fields.map((field, index) => /* @__PURE__ */ jsx(AppearanceButton, { to: `${index}`, elementType: Link, children: field.name }, field.key)) }), + /* @__PURE__ */ jsx("div", { className: "text-right", children: /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "primary", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + size: "xs", + onClick: () => { + const id = nanoid(10); + append({ + name: trans( + message("New menu :number", { + values: { number: fields.length + 1 } + }) + ), + id, + positions: [], + items: [] + }); + navigate(`${fields.length}`); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Create menu" }) + } + ) }) + ] }); +} +function AddMenuItemDialog({ + title = /* @__PURE__ */ jsx(Trans, { message: "Add menu item" }) +}) { + const { data } = useValueLists(["menuItemCategories"]); + const categories = (data == null ? void 0 : data.menuItemCategories) || []; + const routeItems = useAvailableRoutes(); + return /* @__PURE__ */ jsxs(Dialog, { size: "sm", children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: title }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs(Accordion, { variant: "outline", children: [ + /* @__PURE__ */ jsx( + AccordionItem, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Link" }), + bodyClassName: "max-h-240 overflow-y-auto", + children: /* @__PURE__ */ jsx(AddCustomLink, {}) + } + ), + /* @__PURE__ */ jsx( + AccordionItem, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Route" }), + bodyClassName: "max-h-240 overflow-y-auto", + children: /* @__PURE__ */ jsx(AddRoute, { items: routeItems }) + } + ), + categories.map((category) => /* @__PURE__ */ jsx( + AccordionItem, + { + label: /* @__PURE__ */ jsx(Trans, { message: category.name }), + children: /* @__PURE__ */ jsx(AddRoute, { items: category.items }) + }, + category.name + )) + ] }) }) + ] }); +} +function AddCustomLink() { + const form = useForm({ + defaultValues: { + id: nanoid(6), + type: "link", + target: "_blank" + } + }); + const { close } = useDialogContext(); + return /* @__PURE__ */ jsxs( + Form$1, + { + form, + onSubmit: (value) => { + close(value); + }, + children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + required: true, + name: "label", + label: /* @__PURE__ */ jsx(Trans, { message: "Label" }), + className: "mb-20" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + required: true, + type: "url", + name: "action", + placeholder: "https://", + label: /* @__PURE__ */ jsx(Trans, { message: "Url" }), + className: "mb-20" + } + ), + /* @__PURE__ */ jsx("div", { className: "text-right", children: /* @__PURE__ */ jsx(Button, { type: "submit", variant: "flat", color: "primary", size: "xs", children: /* @__PURE__ */ jsx(Trans, { message: "Add to menu" }) }) }) + ] + } + ); +} +function AddRoute({ items }) { + const { close } = useDialogContext(); + return /* @__PURE__ */ jsx(List, { children: items.map((item) => { + return /* @__PURE__ */ jsx( + ListItem, + { + startIcon: /* @__PURE__ */ jsx(AddIcon, { size: "sm" }), + onSelected: () => { + if (item.label) { + const last = item.label.split("/").pop(); + item.label = last ? ucFirst(last) : item.label; + item.id = nanoid(6); + } + close(item); + }, + children: item.label + }, + item.id + ); + }) }); +} +const DragIndicatorIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" }), + "DragIndicatorOutlined" +); +const DeleteIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M16 9v10H8V9h8m-1.5-6h-5l-1 1H5v2h14V4h-3.5l-1-1zM18 7H6v12c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7z" }), + "DeleteOutlined" +); +const dropdownMenu = "/assets/dropdown-menu-c9b3bd6a.svg"; +function MenuEditor() { + const { menuIndex } = useParams(); + const navigate = useNavigate(); + const { getValues } = useFormContext(); + const formPath = `settings.menus.${menuIndex}`; + const menu = getValues(formPath); + useEffect(() => { + if (!menu) { + navigate("/admin/appearance/menus"); + } else { + appearanceState().preview.setHighlight(`[data-menu-id="${menu.id}"]`); + } + }, [navigate, menu]); + if (!menu) { + return null; + } + return /* @__PURE__ */ jsx(MenuEditorSection, { formPath }); +} +function MenuEditorSection({ formPath }) { + const { + site: { has_mobile_app } + } = useSettings(); + const menuSectionConfig = useAppearanceStore( + (s) => { + var _a2; + return (_a2 = s.config) == null ? void 0 : _a2.sections.menus.config; + } + ); + const menuPositions = useMemo(() => { + const positions = [...menuSectionConfig == null ? void 0 : menuSectionConfig.positions]; + if (has_mobile_app) { + positions.push("mobile-app-about"); + } + return positions.map((position) => ({ + key: position, + name: position.replaceAll("-", " ") + })); + }, [menuSectionConfig, has_mobile_app]); + const fieldArray = useFieldArray({ + name: `${formPath}.items`, + keyName: "key" + }); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs("div", { className: "mb-30 border-b pb-30", children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: `${formPath}.name`, + label: /* @__PURE__ */ jsx(Trans, { message: "Menu name" }), + className: "mb-20", + autoFocus: true + } + ), + /* @__PURE__ */ jsx( + FormChipField, + { + chipSize: "sm", + name: `${formPath}.positions`, + valueKey: "id", + label: /* @__PURE__ */ jsx(Trans, { message: "Menu positions" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Where should this menu appear on the site" }), + children: menuPositions.map((item) => /* @__PURE__ */ jsx(Item, { value: item.key, capitalizeFirst: true, children: item.name }, item.key)) + } + ) + ] }), + /* @__PURE__ */ jsx(MenuItemsManager, { fieldArray }), + /* @__PURE__ */ jsx("div", { className: "text-right", children: /* @__PURE__ */ jsx(DeleteMenuTrigger, {}) }) + ] }); +} +function MenuItemsManager({ fieldArray: { append, fields, move } }) { + const navigate = useNavigate(); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs("div", { className: "flex flex-shrink-0 items-center justify-between gap-16", children: [ + /* @__PURE__ */ jsx(Trans, { message: "Menu items" }), + /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "popover", + placement: "right", + offset: 20, + onClose: (menuItemConfig) => { + if (menuItemConfig) { + append({ ...menuItemConfig }); + navigate(`items/${fields.length}`); + } + }, + children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "primary", + size: "xs", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + children: /* @__PURE__ */ jsx(Trans, { message: "Add" }) + } + ), + /* @__PURE__ */ jsx(AddMenuItemDialog, {}) + ] + } + ) + ] }), + /* @__PURE__ */ jsxs("div", { className: "mt-20 flex-shrink-0", children: [ + fields.map((item, index) => /* @__PURE__ */ jsx( + MenuListItem, + { + item, + items: fields, + index, + onSortEnd: (oldIndex, newIndex) => { + move(oldIndex, newIndex); + } + }, + item.key + )), + !fields.length ? /* @__PURE__ */ jsx( + IllustratedMessage, + { + size: "xs", + className: "my-40", + image: /* @__PURE__ */ jsx(SvgImage, { src: dropdownMenu }), + title: /* @__PURE__ */ jsx(Trans, { message: "No menu items yet" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Click “add“ button to start adding links, pages, routes and other items to this menu. " }) + } + ) : null + ] }) + ] }); +} +function DeleteMenuTrigger() { + const navigate = useNavigate(); + const { menuIndex } = useParams(); + const { fields, remove } = useFieldArray({ + name: "settings.menus", + keyName: "key" + }); + if (!menuIndex) + return null; + const menu = fields[+menuIndex]; + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (isConfirmed) => { + if (isConfirmed) { + const index = fields.findIndex((m2) => m2.id === menu.id); + remove(index); + navigate("/admin/appearance/menus"); + } + }, + children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "danger", + size: "xs", + startIcon: /* @__PURE__ */ jsx(DeleteIcon, {}), + children: /* @__PURE__ */ jsx(Trans, { message: "Delete menu" }) + } + ), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isDanger: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Delete menu" }), + body: /* @__PURE__ */ jsx( + Trans, + { + message: "Are you sure you want to delete “:name“?", + values: { name: menu.name } + } + ), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ) + ] + } + ); +} +function MenuListItem({ item, items, index, onSortEnd }) { + const ref = useRef(null); + const { sortableProps, dragHandleRef } = useSortable({ + item, + items, + type: "menuEditorSortable", + ref, + onSortEnd, + strategy: "liveSort" + }); + const Icon = item.icon && createSvgIconFromTree(item.icon); + const iconOnlyLabel = /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4 text-xs text-muted", children: [ + Icon && /* @__PURE__ */ jsx(Icon, { size: "sm" }), + "(", + /* @__PURE__ */ jsx(Trans, { message: "No label..." }), + ")" + ] }); + return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx( + AppearanceButton, + { + elementType: Link, + to: `items/${index}`, + ref, + ...sortableProps, + children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-10", children: [ + /* @__PURE__ */ jsx(IconButton, { ref: dragHandleRef, size: "sm", children: /* @__PURE__ */ jsx(DragIndicatorIcon, { className: "text-muted hover:cursor-move" }) }), + /* @__PURE__ */ jsx("div", { children: item.label || iconOnlyLabel }) + ] }) + } + ) }); +} +function MenuItemEditor() { + const { menuIndex, menuItemIndex } = useParams(); + const navigate = useNavigate$1(); + const { getValues } = useFormContext(); + const formPath = `settings.menus.${menuIndex}.items.${menuItemIndex}`; + const item = getValues(formPath); + useEffect(() => { + if (!item) + ; + else { + appearanceState().preview.setHighlight( + `[data-menu-item-id="${item.id}"]` + ); + } + }, [navigate, item]); + if (!item || menuItemIndex == null) { + return null; + } + return /* @__PURE__ */ jsx(MenuItemEditorSection, { formPath }); +} +function MenuItemEditorSection({ formPath }) { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(MenuItemForm, { formPathPrefix: formPath }), + /* @__PURE__ */ jsx("div", { className: "text-right mt-40", children: /* @__PURE__ */ jsx(DeleteItemTrigger, {}) }) + ] }); +} +function DeleteItemTrigger() { + const navigate = useNavigate$1(); + const { menuIndex, menuItemIndex } = useParams(); + const { fields, remove } = useFieldArray({ + name: `settings.menus.${+menuIndex}.items` + }); + if (!menuItemIndex) + return null; + const item = fields[+menuItemIndex]; + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (isConfirmed) => { + if (isConfirmed) { + if (menuItemIndex) { + remove(+menuItemIndex); + navigate(`/admin/appearance/menus/${menuIndex}`); + } + } + }, + children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "danger", + size: "xs", + startIcon: /* @__PURE__ */ jsx(DeleteIcon, {}), + children: /* @__PURE__ */ jsx(Trans, { message: "Delete this item" }) + } + ), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isDanger: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Delete menu item" }), + body: /* @__PURE__ */ jsx( + Trans, + { + message: "Are you sure you want to delete “:name“?", + values: { name: item.label } + } + ), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ) + ] + } + ); +} +function GeneralSection() { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + BrandingImageSelector, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Favicon" }), + description: /* @__PURE__ */ jsx(Trans, { message: "This will generate different size favicons. Image should be at least 512x512 in size." }), + type: "favicon" + } + ), + /* @__PURE__ */ jsx( + BrandingImageSelector, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Light logo" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Will be used on dark backgrounds." }), + type: "logo_light" + } + ), + /* @__PURE__ */ jsx( + BrandingImageSelector, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Dark logo" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Will be used on light backgrounds. Will default to light logo if left empty." }), + type: "logo_dark" + } + ), + /* @__PURE__ */ jsx( + BrandingImageSelector, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Mobile light logo" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Will be used on light backgrounds on mobile. Will default to desktop logo if left empty." }), + type: "logo_light_mobile" + } + ), + /* @__PURE__ */ jsx( + BrandingImageSelector, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Mobile dark logo" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Will be used on dark backgrounds on mobile. Will default to desktop if left empty." }), + type: "logo_dark_mobile" + } + ), + /* @__PURE__ */ jsx(SiteNameTextField, {}), + /* @__PURE__ */ jsx(SiteDescriptionTextArea, {}) + ] }); +} +function BrandingImageSelector({ label, description, type }) { + const defaultValue = useAppearanceStore( + (s) => { + var _a2; + return (_a2 = s.defaults) == null ? void 0 : _a2.settings.branding[type]; + } + ); + return /* @__PURE__ */ jsx( + FormImageSelector, + { + name: `settings.branding.${type}`, + className: "border-b pb-30 mb-30", + label, + description, + diskPrefix: "branding_media", + defaultValue, + onChange: () => { + appearanceState().preview.setHighlight('[data-logo="navbar"]'); + } + } + ); +} +function SiteNameTextField() { + return /* @__PURE__ */ jsx( + FormTextField, + { + name: "appearance.env.app_name", + required: true, + className: "mt-20", + label: /* @__PURE__ */ jsx(Trans, { message: "Site name" }) + } + ); +} +function SiteDescriptionTextArea() { + return /* @__PURE__ */ jsx( + FormTextField, + { + name: "settings.branding.site_description", + className: "mt-20", + inputElementType: "textarea", + rows: 4, + label: /* @__PURE__ */ jsx(Trans, { message: "Site description" }) + } + ); +} +function randomNumber(min = 1, max = 1e4) { + const randomBuffer = new Uint32Array(1); + window.crypto.getRandomValues(randomBuffer); + const number = randomBuffer[0] / (4294967295 + 1); + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(number * (max - min + 1)) + min; +} +function ThemeList() { + const { trans } = useTrans(); + const navigate = useNavigate(); + const { + data: { themes } + } = useBootstrapData(); + const { fields, append } = useFieldArray({ + name: "appearance.themes.all", + keyName: "key" + }); + useEffect(() => { + if (themes.selectedThemeId) { + appearanceState().preview.setActiveTheme(themes.selectedThemeId); + } + }, [themes.selectedThemeId]); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx("div", { className: "mb-20", children: /* @__PURE__ */ jsx( + Button, + { + size: "xs", + variant: "outline", + color: "primary", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + onClick: () => { + var _a2; + const lightThemeColors = (_a2 = appearanceState().defaults) == null ? void 0 : _a2.appearance.themes.light; + append({ + id: randomNumber(), + name: trans(message("New theme")), + values: lightThemeColors + }); + navigate(`${fields.length + 1}`); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "New theme" }) + } + ) }), + fields.map((field, index) => /* @__PURE__ */ jsx(AppearanceButton, { to: `${index}`, elementType: NavLink, children: field.name }, field.key)) + ] }); +} +const AceEditor = React.lazy(() => import("./ace-editor-481d400c.mjs")); +function AceDialog({ + defaultValue, + mode = "html", + title, + onSave, + isSaving, + footerStartAction, + beautify, + editorRef +}) { + const [value, setValue] = useState(defaultValue); + const [isValid, setIsValid] = useState(true); + return /* @__PURE__ */ jsxs(Dialog, { size: "fullscreen", className: "h-full w-full", children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: title }), + /* @__PURE__ */ jsx(DialogBody, { className: "relative flex-auto", padding: "p-0", children: /* @__PURE__ */ jsx( + Suspense, + { + fallback: /* @__PURE__ */ jsx("div", { className: "flex h-400 w-full items-center justify-center", children: /* @__PURE__ */ jsx( + ProgressCircle, + { + "aria-label": "Loading editor...", + isIndeterminate: true, + size: "md" + } + ) }), + children: /* @__PURE__ */ jsx( + AceEditor, + { + beautify, + mode, + onChange: (newValue) => setValue(newValue), + defaultValue: value || "", + onIsValidChange: setIsValid, + editorRef + } + ) + } + ) }), + /* @__PURE__ */ jsx( + Footer, + { + disabled: !isValid || isSaving, + value, + onSave, + startAction: footerStartAction + } + ) + ] }); +} +function Footer({ disabled, value, onSave, startAction }) { + const { close } = useDialogContext(); + return /* @__PURE__ */ jsxs(DialogFooter, { dividerTop: true, startAction, children: [ + /* @__PURE__ */ jsx(Button, { onClick: () => close(), children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx( + Button, + { + disabled, + variant: "flat", + color: "primary", + onClick: () => { + if (onSave) { + onSave(value); + } else { + close(value); + } + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ) + ] }); +} +function useSeoTags(name) { + return useQuery({ + queryKey: ["admin", "seo-tags", name], + queryFn: () => fetchTags(name) + }); +} +function fetchTags(name) { + return apiClient.get(`admin/appearance/seo-tags/${name}`).then((response) => response.data); +} +function useUpdateSeoTags(name) { + const queryClient2 = useQueryClient(); + return useMutation({ + mutationFn: (payload) => updateTags(name, payload.tags), + onSuccess: async () => { + await queryClient2.invalidateQueries({ + queryKey: ["admin", "seo-tags", name] + }); + toast(message("Updated SEO tags")); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function updateTags(name, tags) { + return apiClient.put(`admin/appearance/seo-tags/${name}`, { tags }).then((r) => r.data); +} +const pages = ((_a = mergedAppearanceConfig.sections["seo-settings"].config) == null ? void 0 : _a.pages) || []; +const names = pages.map((page) => page.key); +function SeoSection() { + const { isLoading } = useSeoTags(names); + if (isLoading) { + return /* @__PURE__ */ jsx(FullPageLoader, {}); + } + return /* @__PURE__ */ jsx(Fragment, { children: pages.map((page) => /* @__PURE__ */ jsx(TagEditorTrigger, { label: page.label, name: page.key }, page.key)) }); +} +function TagEditorTrigger({ label, name }) { + const { data, isLoading } = useSeoTags(names); + return /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(AppearanceButton, { disabled: isLoading, children: /* @__PURE__ */ jsx(Trans, { ...label }) }), + data ? /* @__PURE__ */ jsx(TagsEditorDialog, { name, value: data[name] }) : null + ] }); +} +function TagsEditorDialog({ name, value }) { + const { close } = useDialogContext(); + const updateTags2 = useUpdateSeoTags(name); + const editorRef = useRef(null); + const resetButton = /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "primary", + onClick: () => { + if (editorRef.current) { + editorRef.current.editor.setValue(value.original); + } + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Reset to original" }) + } + ); + return /* @__PURE__ */ jsx( + AceDialog, + { + mode: "php_laravel_blade", + title: /* @__PURE__ */ jsx(Trans, { message: "Edit SEO meta tags" }), + footerStartAction: resetButton, + editorRef, + defaultValue: value.custom || value.original, + isSaving: updateTags2.isPending, + beautify: false, + onSave: (newValue) => { + if (newValue != null) { + updateTags2.mutate( + { tags: newValue }, + { + onSuccess: () => close() + } + ); + } + } + } + ); +} +function CustomCodeSection() { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(CustomCodeDialogTrigger, { mode: "css" }), + /* @__PURE__ */ jsx(CustomCodeDialogTrigger, { mode: "html" }) + ] }); +} +function CustomCodeDialogTrigger({ mode }) { + const { getValues } = useFormContext(); + const { setValue } = useFormContext(); + const title = mode === "html" ? /* @__PURE__ */ jsx(Trans, { message: "Custom HTML & JavaScript" }) : /* @__PURE__ */ jsx(Trans, { message: "Custom CSS" }); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (newValue) => { + if (newValue != null) { + setValue(`appearance.custom_code.${mode}`, newValue, { + shouldDirty: true + }); + appearanceState().preview.setCustomCode(mode, newValue); + } + }, + children: [ + /* @__PURE__ */ jsx(AppearanceButton, { children: title }), + /* @__PURE__ */ jsx( + AceDialog, + { + title, + defaultValue: getValues(`appearance.custom_code.${mode}`) || "", + mode + } + ) + ] + } + ); +} +const articlesSvg = "/assets/articles-8bfd9f17.svg"; +const USER_MODEL = "user"; +const CustomPageDatatableFilters = (config) => { + const dynamicFilters = config.customPages.types.length > 1 ? [ + { + control: { + type: FilterControlType.Select, + defaultValue: "default", + options: config.customPages.types.map((type) => ({ + value: type.type, + label: type.label, + key: type.type + })) + }, + key: "type", + label: message("Type"), + description: message("Type of the page"), + defaultOperator: FilterOperator.eq + } + ] : []; + return [ + { + key: "user_id", + label: message("User"), + description: message("User page was created by"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.SelectModel, + model: USER_MODEL + } + }, + ...dynamicFilters, + createdAtFilter({ + description: message("Date page was created") + }), + updatedAtFilter({ + description: message("Date page was last updated") + }) + ]; +}; +const CustomPageDatatableColumns = [ + { + key: "slug", + allowsSorting: true, + width: "flex-2 min-w-200", + visibleInMode: "all", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Slug" }), + body: (page) => /* @__PURE__ */ jsx(Link, { target: "_blank", to: `/pages/${page.slug}`, className: LinkStyle, children: page.slug }) + }, + { + key: "user_id", + allowsSorting: true, + width: "flex-2 min-w-140", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Owner" }), + body: (page) => page.user && /* @__PURE__ */ jsx( + NameWithAvatar, + { + image: page.user.avatar, + label: page.user.display_name, + description: page.user.email + } + ) + }, + { + key: "type", + maxWidth: "max-w-100", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Type" }), + body: (page) => /* @__PURE__ */ jsx(Trans, { message: page.type }) + }, + { + key: "updated_at", + allowsSorting: true, + width: "w-100", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (page) => /* @__PURE__ */ jsx(FormattedDate, { date: page.updated_at }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + align: "end", + width: "w-84 flex-shrink-0", + visibleInMode: "all", + body: (page) => /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + className: "text-muted", + elementType: Link, + to: `${page.id}/edit`, + children: /* @__PURE__ */ jsx(EditIcon, {}) + } + ) + } +]; +function CustomPageDatablePage() { + const config = useContext(SiteConfigContext); + const filters = useMemo(() => { + return CustomPageDatatableFilters(config); + }, [config]); + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "custom-pages", + title: /* @__PURE__ */ jsx(Trans, { message: "Custom pages" }), + filters, + columns: CustomPageDatatableColumns, + queryParams: { with: "user" }, + actions: /* @__PURE__ */ jsx(Actions$e, {}), + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: articlesSvg, + title: /* @__PURE__ */ jsx(Trans, { message: "No pages have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching pages" }) + } + ) + } + ); +} +function Actions$e() { + return /* @__PURE__ */ jsx(DataTableAddItemButton, { elementType: Link, to: "new", children: /* @__PURE__ */ jsx(Trans, { message: "New page" }) }); +} +const AppSettingsNavConfig = [ + { label: message("Local search"), to: "search" }, + { label: message("Content"), to: "content" }, + { label: message("Videos"), to: "videos" } +]; +const filteredSettingsNavConfig = [ + { label: message("General"), to: "general" }, + ...AppSettingsNavConfig, + getBootstrapData().settings.billing.integrated && { + label: message("Subscriptions"), + to: "subscriptions" + }, + { label: message("Localization"), to: "localization" }, + { + label: message("Authentication"), + to: "authentication" + }, + { label: message("Uploading"), to: "uploading" }, + { label: message("Outgoing email"), to: "outgoing-email" }, + { label: message("Cache"), to: "cache" }, + { label: message("Analytics"), to: "analytics" }, + { label: message("Logging"), to: "logging" }, + { label: message("Queue"), to: "queue" }, + { label: message("Recaptcha"), to: "recaptcha" }, + { label: message("GDPR"), to: "gdpr" }, + { + label: message("Menus"), + to: "/admin/appearance/menus" + }, + { + label: message("Seo"), + to: "/admin/appearance/seo-settings" + }, + { + label: message("Themes"), + to: "/admin/appearance/themes" + } +].filter(Boolean); +const SettingsNavConfig = filteredSettingsNavConfig; +function SettingsLayout({ className }) { + const isMobile = useIsMobileMediaQuery(); + return /* @__PURE__ */ jsxs( + "div", + { + className: clsx( + className, + "container mx-auto min-h-full items-start gap-30 p-24 md:flex" + ), + children: [ + /* @__PURE__ */ jsx(StaticPageTitle, { children: /* @__PURE__ */ jsx(Trans, { message: "Settings" }) }), + isMobile ? /* @__PURE__ */ jsx(MobileNav$1, {}) : /* @__PURE__ */ jsx(DesktopNav$1, {}), + /* @__PURE__ */ jsx("div", { className: "relative max-w-500 flex-auto md:px-30", children: /* @__PURE__ */ jsx(Outlet, {}) }) + ] + } + ); +} +function MobileNav$1() { + const { pathname } = useLocation(); + const navigate = useNavigate(); + const value = pathname.split("/").pop(); + return /* @__PURE__ */ jsx( + SelectForwardRef, + { + minWidth: "min-w-none", + className: "mb-24 w-full bg", + selectionMode: "single", + selectedValue: value, + onSelectionChange: (newPage) => { + navigate(newPage); + }, + children: SettingsNavConfig.map((item) => /* @__PURE__ */ jsx(Item, { value: item.to, children: /* @__PURE__ */ jsx(Trans, { ...item.label }) }, item.to)) + } + ); +} +function DesktopNav$1() { + return /* @__PURE__ */ jsx("div", { className: "sticky top-24 w-240 flex-shrink-0", children: SettingsNavConfig.map((item) => /* @__PURE__ */ jsx( + NavLink, + { + to: item.to, + className: ({ isActive }) => clsx( + "mb-8 block whitespace-nowrap rounded-button p-14 text-sm transition-bg-color", + isActive ? "bg-primary/6 font-semibold text-primary" : "hover:bg-hover" + ), + children: /* @__PURE__ */ jsx(Trans, { ...item.label }) + }, + item.to + )) }); +} +function useAdminSettings() { + return useQuery({ + queryKey: ["fetchAdminSettings"], + queryFn: () => fetchAdminSettings(), + // prevent automatic re-fetching so diffing with previous settings work properly + staleTime: Infinity + }); +} +function fetchAdminSettings() { + return apiClient.get("settings").then((response) => response.data); +} +function GenerateSitemap() { + return apiClient.post("sitemap/generate").then((r) => r.data); +} +function useGenerateSitemap() { + return useMutation({ + mutationFn: () => GenerateSitemap(), + onSuccess: () => { + toast(message("Sitemap generated")); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function useUpdateAdminSettings(form) { + const { data: original } = useAdminSettings(); + return useMutation({ + mutationFn: (props) => { + var _a2, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k; + if ((_b = (_a2 = props.client) == null ? void 0 : _a2.cookie_notice) == null ? void 0 : _b.button) { + props.client.cookie_notice.button = JSON.stringify( + props.client.cookie_notice.button + ); + } + if ((_d = (_c = props.client) == null ? void 0 : _c.registration) == null ? void 0 : _d.policies) { + props.client.registration.policies = JSON.stringify( + props.client.registration.policies + ); + } + if ((_f = (_e = props.client) == null ? void 0 : _e.artistPage) == null ? void 0 : _f.tabs) { + props.client.artistPage.tabs = JSON.stringify( + props.client.artistPage.tabs + ); + } + if ((_h = (_g = props.client) == null ? void 0 : _g.title_page) == null ? void 0 : _h.sections) { + props.client.title_page.sections = JSON.stringify( + props.client.title_page.sections + ); + } + if ((_i = props.client) == null ? void 0 : _i.incoming_email) { + props.client.incoming_email = JSON.stringify( + props.client.incoming_email + ); + } + if ((_k = (_j = props.client) == null ? void 0 : _j.publish) == null ? void 0 : _k.default_credentials) { + props.client.publish.default_credentials = JSON.stringify( + props.client.publish.default_credentials + ); + } + const client = props.client ? diff(original.client, props.client) : null; + const server = props.server ? diff(original.server, props.server) : null; + return updateAdminSettings({ + client, + server, + files: props.files + }); + }, + onSuccess: () => { + toast(message("Settings updated"), { + position: "bottom-right" + }); + queryClient.invalidateQueries({ queryKey: ["fetchAdminSettings"] }); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function updateAdminSettings({ + client, + server, + files +}) { + const formData = new FormData(); + if (client) { + formData.set("client", JSON.stringify(dot.dot(client))); + } + if (server) { + formData.set("server", JSON.stringify(dot.dot(server))); + } + Object.entries(files || {}).forEach(([key, file]) => { + formData.set(key, file); + }); + return apiClient.post("settings", formData, { + headers: { + "Content-Type": "multipart/form-data" + } + }).then((r) => r.data); +} +function SettingsPanel({ + title, + description, + children, + transformValues +}) { + const { data } = useAdminSettings(); + return /* @__PURE__ */ jsxs("section", { children: [ + /* @__PURE__ */ jsxs("div", { className: "mb-40", children: [ + /* @__PURE__ */ jsx("h2", { className: "mb-4 text-xl", children: title }), + /* @__PURE__ */ jsx("div", { className: "text-sm text-muted", children: description }) + ] }), + data ? /* @__PURE__ */ jsx(FormWrapper, { defaultValues: data, transformValues, children }) : /* @__PURE__ */ jsx(ProgressCircle, { isIndeterminate: true, "aria-label": "Loading settings..." }) + ] }); +} +function FormWrapper({ + children, + defaultValues, + transformValues +}) { + const form = useForm({ defaultValues }); + const updateSettings = useUpdateAdminSettings(form); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs( + Form$1, + { + form, + onBeforeSubmit: () => { + const errors = form.formState.errors; + const keys = Object.keys(errors).filter((key) => { + return key.endsWith("_group"); + }); + form.clearErrors(keys); + }, + onSubmit: (value) => { + value = transformValues ? transformValues(value) : value; + updateSettings.mutate(value); + }, + children: [ + children, + /* @__PURE__ */ jsx("div", { className: "mt-40", children: /* @__PURE__ */ jsx( + Button, + { + type: "submit", + variant: "flat", + color: "primary", + disabled: updateSettings.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Update" }) + } + ) }) + ] + } + ), + updateSettings.isPending && /* @__PURE__ */ jsx( + ProgressBar, + { + size: "xs", + className: "absolute -bottom-14 left-30 w-full", + isIndeterminate: true, + "aria-label": "Saving settings..." + } + ) + ] }); +} +function SettingsSeparator() { + return /* @__PURE__ */ jsx("div", { className: "h-1 bg-divider my-30" }); +} +const LinkIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M17 7h-4v2h4c1.65 0 3 1.35 3 3s-1.35 3-3 3h-4v2h4c2.76 0 5-2.24 5-5s-2.24-5-5-5zm-6 8H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-2zm-3-4h8v2H8z" }), + "LinkOutlined" +); +function LearnMoreLink({ link, className }) { + const { site } = useSettings(); + if (site.hide_docs_button) { + return null; + } + return /* @__PURE__ */ jsxs("div", { className: clsx("flex items-center gap-8", className), children: [ + /* @__PURE__ */ jsx(LinkIcon, { size: "sm" }), + /* @__PURE__ */ jsx(ExternalLink, { href: link, children: /* @__PURE__ */ jsx(Trans, { message: "Learn more" }) }) + ] }); +} +function GeneralSettings() { + return /* @__PURE__ */ jsxs( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "General" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Configure site url, homepage, theme and other general settings." }), + children: [ + /* @__PURE__ */ jsx(SiteUrlSection, {}), + /* @__PURE__ */ jsx(SettingsSeparator, {}), + /* @__PURE__ */ jsx(HomepageSection, {}), + /* @__PURE__ */ jsx(SettingsSeparator, {}), + /* @__PURE__ */ jsx(ThemeSection, {}), + /* @__PURE__ */ jsx(SettingsSeparator, {}), + /* @__PURE__ */ jsx(SitemapSection, {}) + ] + } + ); +} +function SiteUrlSection() { + const { data } = useAdminSettings(); + if (!data) + return null; + let append = null; + const server = data.server; + const isInvalid = server.newAppUrl && server.newAppUrl !== server.app_url; + if (isInvalid) { + append = /* @__PURE__ */ jsx("div", { className: "mt-20 text-sm text-danger", children: /* @__PURE__ */ jsx( + Trans, + { + values: { + baseUrl: server.app_url, + currentUrl: server.newAppUrl, + b: (chunks) => /* @__PURE__ */ jsx("b", { children: chunks }) + }, + message: "Base site url is set as :baseUrl in configuration, but current url is :currentUrl. It is recommended to set the primary url you want to use in configuration file and then redirect all other url versions to this primary version via cpanel or .htaccess file." + } + ) }); + } + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: !!isInvalid, + name: "server.app_url", + label: /* @__PURE__ */ jsx(Trans, { message: "Primary site url" }), + description: /* @__PURE__ */ jsx(LearnMoreLink, { link: "https://support.vebto.com/hc/articles/35/primary-site-url" }) + } + ), + append + ] }); +} +function HomepageSection() { + var _a2, _b; + const { watch } = useFormContext(); + const { homepage } = useContext(SiteConfigContext); + const { data } = useValueLists(["menuItemCategories"]); + const selectedType = watch("client.homepage.type"); + return /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsxs( + FormSelect, + { + name: "client.homepage.type", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Site home page" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Which page should be used as site homepage." }), + children: [ + homepage.options.map((option) => /* @__PURE__ */ jsx(Item, { value: option.value, children: /* @__PURE__ */ jsx(Trans, { ...option.label }) }, option.value)), + (_a2 = data == null ? void 0 : data.menuItemCategories) == null ? void 0 : _a2.map((category) => /* @__PURE__ */ jsx(Item, { value: category.type, children: category.name }, category.type)) + ] + } + ), + (_b = data == null ? void 0 : data.menuItemCategories) == null ? void 0 : _b.map((category) => { + return selectedType === category.type ? /* @__PURE__ */ jsx( + FormSelect, + { + className: "mt-24", + name: "client.homepage.value", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Homepage :name", values: { name: category.name } }), + children: category.items.map((item) => /* @__PURE__ */ jsx(Item, { value: item.model_id, children: item.label }, item.label)) + }, + category.name + ) : null; + }) + ] }); +} +function ThemeSection() { + const { + data: { themes } + } = useBootstrapData(); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs( + FormSelect, + { + className: "mb-20", + name: "client.themes.default_id", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Default site theme" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Which theme to use for users that have not chosen a theme manually." }), + children: [ + /* @__PURE__ */ jsx(Item, { value: 0, children: /* @__PURE__ */ jsx(Trans, { message: "System" }) }), + themes.all.map((theme) => /* @__PURE__ */ jsx(Item, { value: theme.id, children: theme.name }, theme.id)) + ] + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "client.themes.user_change", + description: /* @__PURE__ */ jsx(Trans, { message: "Allow users to manually change site theme." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Allow theme change" }) + } + ) + ] }); +} +function SitemapSection() { + const generateSitemap = useGenerateSitemap(); + const { base_url } = useSettings(); + const url = `${base_url}/storage/sitemaps/sitemap-index.xml`; + const link = /* @__PURE__ */ jsx(ExternalLink, { href: url, children: url }); + return /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + size: "xs", + color: "primary", + disabled: generateSitemap.isPending, + onClick: () => { + generateSitemap.mutate(); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Generate sitemap" }) + } + ), + /* @__PURE__ */ jsx("div", { className: "mt-14 text-sm text-muted", children: /* @__PURE__ */ jsx( + Trans, + { + message: "Once generated, sitemap url will be: :url", + values: { + url: link + } + } + ) }) + ] }); +} +function colorToThemeValue(color) { + return parseColor(color).toString("rgb").replace("rgb(", "").replace(")", "").replace(/, ?/g, " "); +} +function ThemeSettingsDialogTrigger() { + const { getValues, setValue } = useFormContext(); + const { themeIndex } = useParams(); + const theme = getValues(`appearance.themes.all.${+themeIndex}`); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (value) => { + if (!value) + return; + getValues("appearance.themes.all").forEach((currentTheme, index) => { + if (currentTheme.id === value.id) { + setValue(`appearance.themes.all.${index}`, value, { + shouldDirty: true + }); + return; + } + if (value.default_light) { + setValue( + `appearance.themes.all.${index}`, + { ...currentTheme, default_light: false }, + { shouldDirty: true } + ); + return; + } + if (value.default_dark) { + setValue( + `appearance.themes.all.${index}`, + { ...currentTheme, default_dark: false }, + { shouldDirty: true } + ); + return; + } + }); + }, + children: [ + /* @__PURE__ */ jsx( + Button, + { + size: "xs", + variant: "outline", + color: "primary", + startIcon: /* @__PURE__ */ jsx(TuneIcon, {}), + children: /* @__PURE__ */ jsx(Trans, { message: "Settings" }) + } + ), + /* @__PURE__ */ jsx(SettingsDialog, { theme }) + ] + } + ); +} +function SettingsDialog({ theme }) { + const form = useForm({ defaultValues: theme }); + const { close, formId } = useDialogContext(); + useEffect(() => { + const subscription = form.watch((value, { name }) => { + if (name === "default_light" && value.default_light) { + form.setValue("default_dark", false); + } + if (name === "default_dark" && value.default_dark) { + form.setValue("default_light", false); + } + }); + return () => subscription.unsubscribe(); + }, [form]); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Update settings" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs( + Form$1, + { + form, + id: formId, + onSubmit: (values) => { + close(values); + }, + children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Name" }), + className: "mb-30", + autoFocus: true + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "is_dark", + className: "mb-20 pb-20 border-b", + description: /* @__PURE__ */ jsx(Trans, { message: "Whether this theme has light text on dark background." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Dark theme" }) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "default_light", + className: "mb-30", + description: /* @__PURE__ */ jsx(Trans, { message: "When light mode is selected, this theme will be used." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Default for light mode" }) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "default_dark", + description: /* @__PURE__ */ jsx(Trans, { message: "When dark mode is selected, this theme will be used." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Default for dark mode" }) + } + ) + ] + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx( + Button, + { + onClick: () => { + close(); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) + } + ), + /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + type: "submit", + form: formId, + disabled: !form.formState.isDirty, + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ) + ] }) + ] }); +} +const RestartAltIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M6 13c0-1.65.67-3.15 1.76-4.24L6.34 7.34C4.9 8.79 4 10.79 4 13c0 4.08 3.05 7.44 7 7.93v-2.02c-2.83-.48-5-2.94-5-5.91zm14 0c0-4.42-3.58-8-8-8-.06 0-.12.01-.18.01l1.09-1.09L11.5 2.5 8 6l3.5 3.5 1.41-1.41-1.08-1.08c.06 0 .12-.01.17-.01 3.31 0 6 2.69 6 6 0 2.97-2.17 5.43-5 5.91v2.02c3.95-.49 7-3.85 7-7.93z" }), + "RestartAltOutlined" +); +function ThemeMoreOptionsButton() { + const navigate = useNavigate$1(); + const { themeIndex } = useParams(); + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const { setValue, getValues } = useFormContext(); + const { fields, remove } = useFieldArray({ + name: "appearance.themes.all" + }); + const deleteTheme = () => { + if (fields.length <= 1) { + toast.danger(message("At least one theme is required")); + return; + } + if (themeIndex) { + navigate("/admin/appearance/themes"); + remove(+themeIndex); + setValue("appearance.themes.selectedThemeId", null); + } + }; + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs( + MenuTrigger, + { + onItemSelected: (key) => { + if (key === "delete") { + setConfirmDialogOpen(true); + } else if (key === "reset") { + const path = `appearance.themes.all.${+themeIndex}`; + const defaultColors = getValues(`${path}.is_dark`) ? appearanceState().defaults.appearance.themes.dark : appearanceState().defaults.appearance.themes.light; + Object.entries(defaultColors).forEach(([colorName, themeValue]) => { + appearanceState().preview.setThemeValue(colorName, themeValue); + }); + appearanceState().preview.setThemeFont(null); + setValue(`${path}.values`, defaultColors, { + shouldDirty: true + }); + setValue(`${path}.font`, void 0, { + shouldDirty: true + }); + } + }, + children: [ + /* @__PURE__ */ jsx(IconButton, { size: "md", className: "text-muted", children: /* @__PURE__ */ jsx(MoreVertIcon, {}) }), + /* @__PURE__ */ jsxs(Menu, { children: [ + /* @__PURE__ */ jsx(Item, { value: "reset", startIcon: /* @__PURE__ */ jsx(RestartAltIcon, {}), children: /* @__PURE__ */ jsx(Trans, { message: "Reset colors" }) }), + /* @__PURE__ */ jsx(Item, { value: "delete", startIcon: /* @__PURE__ */ jsx(DeleteIcon, {}), children: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) }) + ] }) + ] + } + ), + /* @__PURE__ */ jsx( + DialogTrigger, + { + type: "modal", + isOpen: confirmDialogOpen, + onClose: (isConfirmed) => { + if (isConfirmed) { + deleteTheme(); + } + setConfirmDialogOpen(false); + }, + children: /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isDanger: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Delete theme" }), + body: /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete this theme?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ) + } + ) + ] }); +} +const navbarColorMap = [ + { + label: message("Accent"), + value: "primary", + bgColor: "bg-primary", + previewBgColor: "text-primary" + }, + { + label: message("Background"), + value: "bg", + bgColor: "bg-background", + previewBgColor: "text-background" + }, + { + label: message("Background alt"), + value: "bg-alt", + bgColor: "bg-alt", + previewBgColor: "text-background-alt" + }, + { + label: message("Transparent"), + value: "transparent", + bgColor: "bg-transparent", + previewBgColor: "text-transparent" + } +]; +function NavbarColorPicker() { + var _a2; + const { themeIndex } = useParams(); + const { watch, setValue } = useFormContext(); + const key = `appearance.themes.all.${themeIndex}.values.--be-navbar-color`; + const selectedValue = watch(key); + const previewColor = (_a2 = navbarColorMap.find(({ value }) => value === selectedValue)) == null ? void 0 : _a2.previewBgColor; + return /* @__PURE__ */ jsxs( + MenuTrigger, + { + placement: "right", + selectionMode: "single", + selectedValue, + onSelectionChange: (value) => { + setValue(key, value, { shouldDirty: true }); + }, + children: [ + /* @__PURE__ */ jsx( + AppearanceButton, + { + startIcon: /* @__PURE__ */ jsx( + ColorIcon, + { + viewBox: "0 0 48 48", + className: clsx("icon-lg", previewColor) + } + ), + children: /* @__PURE__ */ jsx(Trans, { message: "Navbar" }) + } + ), + /* @__PURE__ */ jsx(Menu, { children: navbarColorMap.map(({ label, value, bgColor }) => /* @__PURE__ */ jsx( + Item, + { + value, + startIcon: /* @__PURE__ */ jsx("div", { className: clsx("h-20 w-20 rounded border", bgColor) }), + children: /* @__PURE__ */ jsx(Trans, { ...label }) + }, + value + )) }) + ] + } + ); +} +function themeValueToHex(value) { + try { + return parseColor(`rgb(${value.split(" ").join(",")})`).toString("hex"); + } catch (e) { + return value; + } +} +const colorList = [ + { + label: message("Background"), + key: "--be-background" + }, + { + label: message("Background alt"), + key: "--be-background-alt" + }, + { + label: message("Foreground"), + key: "--be-foreground-base" + }, + { + label: message("Accent light"), + key: "--be-primary-light" + }, + { + label: message("Accent"), + key: "--be-primary" + }, + { + label: message("Accent dark"), + key: "--be-primary-dark" + }, + { + label: message("Text on accent"), + key: "--be-on-primary" + }, + { + label: message("Chip"), + key: "--be-background-chip" + } +]; +function ThemeEditor() { + const navigate = useNavigate(); + const { themeIndex } = useParams(); + const { getValues, watch } = useFormContext(); + const theme = getValues(`appearance.themes.all.${+themeIndex}`); + const selectedFont = watch( + `appearance.themes.all.${+themeIndex}.font.family` + ); + useEffect(() => { + if (!theme) { + navigate("/admin/appearance/themes"); + } + }, [navigate, theme]); + useEffect(() => { + if (theme == null ? void 0 : theme.id) { + appearanceState().preview.setActiveTheme(theme.id); + } + }, [theme == null ? void 0 : theme.id]); + if (!theme) + return null; + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs("div", { className: "mb-20 flex items-center justify-between gap-10", children: [ + /* @__PURE__ */ jsx(ThemeSettingsDialogTrigger, {}), + /* @__PURE__ */ jsx(ThemeMoreOptionsButton, {}) + ] }), + /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx( + AppearanceButton, + { + elementType: Link, + to: "font", + description: selectedFont ? selectedFont : /* @__PURE__ */ jsx(Trans, { message: "System" }), + children: /* @__PURE__ */ jsx(Trans, { message: "Font" }) + } + ), + /* @__PURE__ */ jsx(AppearanceButton, { elementType: Link, to: "radius", children: /* @__PURE__ */ jsx(Trans, { message: "Rounding" }) }), + /* @__PURE__ */ jsx("div", { className: "mb-6 mt-22 text-sm font-semibold", children: /* @__PURE__ */ jsx(Trans, { message: "Colors" }) }), + /* @__PURE__ */ jsx(NavbarColorPicker, {}), + colorList.map((color) => /* @__PURE__ */ jsx( + ColorPickerTrigger, + { + colorName: color.key, + label: /* @__PURE__ */ jsx(Trans, { ...color.label }), + initialThemeValue: theme.values[color.key], + theme + }, + color.key + )) + ] }) + ] }); +} +function ColorPickerTrigger({ + label, + theme, + colorName, + initialThemeValue +}) { + const { setValue } = useFormContext(); + const { themeIndex } = useParams(); + const [selectedThemeValue, setSelectedThemeValue] = useState(initialThemeValue); + const selectThemeValue = (themeValue) => { + setSelectedThemeValue(themeValue); + appearanceState().preview.setThemeValue(colorName, themeValue); + }; + useEffect(() => { + setSelectedThemeValue(initialThemeValue); + }, [initialThemeValue]); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + value: themeValueToHex(selectedThemeValue), + type: "popover", + placement: "right", + offset: 10, + onValueChange: (newColor) => { + selectThemeValue(colorToThemeValue(newColor)); + }, + onClose: (newColor, { valueChanged, initialValue }) => { + if (newColor && valueChanged) { + setValue( + `appearance.themes.all.${+themeIndex}.values.${colorName}`, + colorToThemeValue(newColor), + { shouldDirty: true } + ); + setValue("appearance.themes.selectedThemeId", theme.id); + } else { + selectThemeValue(initialValue); + } + }, + children: [ + /* @__PURE__ */ jsx( + AppearanceButton, + { + className: "capitalize", + startIcon: /* @__PURE__ */ jsx( + ColorIcon, + { + viewBox: "0 0 48 48", + className: "icon-lg", + style: { fill: `rgb(${selectedThemeValue})` } + } + ), + children: label + } + ), + /* @__PURE__ */ jsx(ColorPickerDialog, {}) + ] + } + ); +} +function JsonChipField({ children, ...props }) { + const { + field: { onChange, onBlur, value = [], ref }, + fieldState: { invalid, error } + } = useController({ + name: props.name + }); + const arrayValue = useMemo(() => { + const mixedValue = value; + return typeof mixedValue === "string" ? JSON.parse(mixedValue) : mixedValue; + }, [value]); + const formProps = { + onChange: (newValue) => { + const jsonValue = JSON.stringify(newValue.map((chip) => chip.name)); + onChange(jsonValue); + }, + onBlur, + value: arrayValue, + invalid, + errorMessage: error == null ? void 0 : error.message + }; + return /* @__PURE__ */ jsx(ChipField, { ref, ...mergeProps(formProps, props) }); +} +function VideoSettings() { + const { trans } = useTrans(); + return /* @__PURE__ */ jsxs( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Video and streaming" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Control how videos are played and displayed on the site." }), + children: [ + /* @__PURE__ */ jsx(ShownVideoTypeSelect, {}), + /* @__PURE__ */ jsx(SortingMethodSelect$1, {}), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-24", + name: "client.streaming.prefer_full", + description: /* @__PURE__ */ jsx( + Trans, + { + message: 'When user clicks on "play" buttons across the site play full movie or episode instead of trailers and clips.' + } + ), + children: /* @__PURE__ */ jsx(Trans, { message: "Prefer full videos" }) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-24", + name: "client.streaming.show_video_selector", + description: /* @__PURE__ */ jsx(Trans, { message: "Show alternative videos on the watch page." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Alternative videos" }) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-24", + name: "client.streaming.show_header_play", + description: /* @__PURE__ */ jsx(Trans, { message: "Whether play button should be shown on main title header." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Header play button" }) + } + ), + /* @__PURE__ */ jsx( + JsonChipField, + { + className: "mb-24", + label: /* @__PURE__ */ jsx(Trans, { message: "Possible video qualities" }), + name: "client.streaming.qualities", + placeholder: trans({ message: "Add another..." }) + } + ) + ] + } + ); +} +function SortingMethodSelect$1() { + return /* @__PURE__ */ jsxs( + FormSelect, + { + className: "mb-24", + name: "client.streaming.default_sort", + label: /* @__PURE__ */ jsx(Trans, { message: "Video sorting" }), + selectionMode: "single", + description: /* @__PURE__ */ jsx(Trans, { message: "When multiple videos are shown on the page, how should they be sorted by default." }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "order:asc", children: /* @__PURE__ */ jsx(Trans, { message: "Manual (order assigned manually in admin area)" }) }), + /* @__PURE__ */ jsx(Item, { value: "created_at:desc", children: /* @__PURE__ */ jsx(Trans, { message: "Date added" }) }), + /* @__PURE__ */ jsx(Item, { value: "name:asc", children: /* @__PURE__ */ jsx(Trans, { message: "Name (a-z)" }) }), + /* @__PURE__ */ jsx(Item, { value: "Language:asc", children: /* @__PURE__ */ jsx(Trans, { message: "Language (a-z)" }) }), + /* @__PURE__ */ jsx(Item, { value: "reports:asc", children: /* @__PURE__ */ jsx(Trans, { message: "Reports (videos with less reports first)" }) }), + /* @__PURE__ */ jsx(Item, { value: "score:desc", children: /* @__PURE__ */ jsx(Trans, { message: "Score (most liked videos first)" }) }) + ] + } + ); +} +function ShownVideoTypeSelect() { + return /* @__PURE__ */ jsxs( + FormSelect, + { + className: "mb-24", + name: "client.streaming.video_panel_content", + label: /* @__PURE__ */ jsx(Trans, { message: "Shown videos" }), + selectionMode: "single", + description: /* @__PURE__ */ jsx(Trans, { message: "What type of videos should be shown in title and episode pages (if there is more then one video attached)." }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "all", children: /* @__PURE__ */ jsx(Trans, { message: "All videos" }) }), + /* @__PURE__ */ jsx(Item, { value: "full", children: /* @__PURE__ */ jsx(Trans, { message: "Full movies and episodes" }) }), + /* @__PURE__ */ jsx(Item, { value: "short", children: /* @__PURE__ */ jsx(Trans, { message: "Short videos (everything except full movies & episodes)" }) }), + /* @__PURE__ */ jsx(Item, { value: "trailer", children: /* @__PURE__ */ jsx(Trans, { message: "Trailers" }) }), + /* @__PURE__ */ jsx(Item, { value: "clip", children: /* @__PURE__ */ jsx(Trans, { message: "Clips" }) }) + ] + } + ); +} +function TabPanels({ children, className }) { + const { selectedTab, isLazy } = useContext(TabContext); + const panelArray = Children.toArray(children).filter((p) => !!p); + let rendered; + if (isLazy) { + const el = panelArray[selectedTab]; + rendered = isValidElement(el) ? cloneElement(panelArray[selectedTab], { + index: selectedTab + }) : null; + } else { + rendered = panelArray.map((panel, index) => { + if (isValidElement(panel)) { + const isSelected = index === selectedTab; + return cloneElement(panel, { + index, + "aria-hidden": !isSelected, + className: !isSelected ? clsx(panel.props.className, "hidden") : panel.props.className + }); + } + return null; + }); + } + return /* @__PURE__ */ jsx("div", { className, children: rendered }); +} +function TabPanel({ + className, + children, + index, + ...domProps +}) { + const { id } = useContext(TabContext); + const [tabIndex, setTabIndex] = useState(0); + const ref = useRef(null); + useLayoutEffect(() => { + if (ref == null ? void 0 : ref.current) { + const update = () => { + const walker = getFocusableTreeWalker(ref.current, { tabbable: true }); + setTabIndex(walker.nextNode() ? void 0 : 0); + }; + update(); + const observer = new MutationObserver(update); + observer.observe(ref.current, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["tabIndex", "disabled"] + }); + return () => { + observer.disconnect(); + }; + } + }, [ref]); + return /* @__PURE__ */ jsx( + "div", + { + tabIndex, + ref, + id: `${id}-${index}-tabpanel`, + "aria-labelledby": `${id}-${index}-tab`, + className: clsx(className, "focus-visible:outline-primary-light"), + role: "tabpanel", + ...domProps, + children + } + ); +} +function ContentSettingsGeneralPanel() { + const { watch } = useFormContext(); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(SortingMethodSelect, {}), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-24", + name: "client.titles.enable_reviews", + description: /* @__PURE__ */ jsx( + Trans, + { + message: "Enable or disable all review functionality across the site." + } + ), + children: /* @__PURE__ */ jsx(Trans, { message: "Enable reviews" }) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-24", + name: "client.titles.enable_comments", + description: /* @__PURE__ */ jsx( + Trans, + { + message: "Enable or disable all comment functionality across the site." + } + ), + children: /* @__PURE__ */ jsx(Trans, { message: "Enable comments" }) + } + ), + watch("client.titles.enable_comments") && /* @__PURE__ */ jsx( + FormSwitch, + { + name: "client.comments.per_video", + description: /* @__PURE__ */ jsx( + Trans, + { + message: "When enabled, individual videos will have their own separate comment section (if there are multiple videos), otherwise comments will be shared by all videos for the same title." + } + ), + children: /* @__PURE__ */ jsx(Trans, { message: "Per video comments" }) + } + ) + ] }); +} +function SortingMethodSelect() { + return /* @__PURE__ */ jsxs( + FormSelect, + { + className: "mb-24", + name: "server.rating_column", + label: /* @__PURE__ */ jsx(Trans, { message: "Rating used for sorting" }), + selectionMode: "single", + description: /* @__PURE__ */ jsx( + Trans, + { + message: "When ordering titles by rating, should local user rating or TheMovieDB rating average be\n used." + } + ), + children: [ + /* @__PURE__ */ jsx(Item, { value: "tmdb_vote_average", children: /* @__PURE__ */ jsx(Trans, { message: "TheMovieDB" }) }), + /* @__PURE__ */ jsx(Item, { value: "local_vote_average", children: /* @__PURE__ */ jsx(Trans, { message: "Local (Ratings and reviews from site users)" }) }) + ] + } + ); +} +function SettingsErrorGroup({ + children, + name, + separatorBottom = true, + separatorTop = true +}) { + const { + formState: { errors } + } = useFormContext(); + const ref = useRef(null); + const error = errors[name]; + useEffect(() => { + var _a2; + if (error) { + (_a2 = ref.current) == null ? void 0 : _a2.scrollIntoView({ behavior: "smooth" }); + } + }, [error]); + return /* @__PURE__ */ jsxs( + "div", + { + className: clsx( + separatorBottom && "border-b mb-20 pb-20", + separatorTop && "border-t mt-20 pt-20", + error && "border-y-error" + ), + ref, + children: [ + children(!!error), + error && /* @__PURE__ */ jsx( + "div", + { + className: "text-danger text-sm mt-20", + dangerouslySetInnerHTML: { __html: error.message } + } + ) + ] + } + ); +} +function ContentSettingsAutomationPanel() { + const { watch } = useFormContext(); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(SearchMethodSelect$1, {}), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-24", + name: "client.content.title_provider", + value: "tmdb", + description: /* @__PURE__ */ jsx(Trans, { message: "This will automatically import, and periodically update, all metadata available on TheMovieDB about the title when user visits that title's page." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Title automation" }) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-24", + name: "client.content.force_season_update", + value: "tmdb", + description: /* @__PURE__ */ jsx(Trans, { message: "When this is enabled, season episodes will be automatically updated, even if title automation is disabled." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Always update seasons" }) + } + ), + /* @__PURE__ */ jsx(SettingsSeparator, {}), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-24", + name: "client.content.people_provider", + value: "tmdb", + description: /* @__PURE__ */ jsx(Trans, { message: "This will automatically import, and periodically update, all metadata available on TheMovieDB about a person, when user visits that person's page." }), + children: /* @__PURE__ */ jsx(Trans, { message: "People automation" }) + } + ), + watch("client.content.people_provider") === "tmdb" && /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-24", + name: "client.content.automate_filmography", + description: /* @__PURE__ */ jsx(Trans, { message: "Whether full filmograpy for a person should be imported from TheMovieDB when auto updating the person metadata." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Full filmography" }) + } + ), + /* @__PURE__ */ jsx(TmdbFields, {}) + ] }); +} +function SearchMethodSelect$1() { + return /* @__PURE__ */ jsxs( + FormSelect, + { + className: "mb-24", + name: "client.content.search_provider", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Search method" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Which method should be used for user facing search on the site." }), + children: [ + /* @__PURE__ */ jsx( + Item, + { + value: "tmdb", + description: /* @__PURE__ */ jsx(Trans, { message: "Search on the site will directly connect to, and search TheMovieDB. Any movie, series and artist available on TheMovieDB will be discoverable via search, without needing to import or create it first." }), + children: /* @__PURE__ */ jsx(Trans, { message: "TheMovieDB" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "local", + description: /* @__PURE__ */ jsx(Trans, { message: "Will only search content that was created or imported from admin area. This can be further configured from 'Local search' settings page." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Local" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "all", + description: /* @__PURE__ */ jsx(Trans, { message: "Will combine search results from both 'Local' and 'TheMovieDB' methods. If there are identical matches, local results will be preferred." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Local and TheMovieDB" }) + } + ) + ] + } + ); +} +function TmdbFields() { + const { data } = useValueLists(["tmdbLanguages"]); + const { watch: w } = useFormContext(); + const shouldShow = [ + w("client.content.people_provider"), + w("client.content.title_provider"), + w("client.content.search_provider") + ].some((provider) => `${provider}`.toLowerCase().includes("tmdb")); + if (!shouldShow) { + return null; + } + return /* @__PURE__ */ jsx(SettingsErrorGroup, { name: "tmdb_group", separatorBottom: false, children: (isInvalid) => /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + name: "server.tmdb_api_key", + label: /* @__PURE__ */ jsx(Trans, { message: "TheMovieDB API Key" }), + className: "mb-24", + required: true + } + ), + /* @__PURE__ */ jsx( + FormSelect, + { + className: "mb-24", + selectionMode: "single", + showSearchField: true, + invalid: isInvalid, + name: "client.tmdb.language", + label: /* @__PURE__ */ jsx(Trans, { message: "TheMovieDB language" }), + description: /* @__PURE__ */ jsx(Trans, { message: "In what language should content be fetched from TMDb. If translation is not available, data will be in original language for that movie or series." }), + children: data == null ? void 0 : data.tmdbLanguages.map(({ code, name }) => /* @__PURE__ */ jsx(Item, { value: code, children: name }, code)) + } + ), + /* @__PURE__ */ jsx(FormSwitch, { name: "client.tmdb.includeAdult", children: /* @__PURE__ */ jsx(Trans, { message: "Import adult content" }) }) + ] }) }); +} +const defaultItems = [ + { name: "episodes", title: { message: "Episode grid" } }, + { name: "seasons", title: { message: "Season grid" } }, + { name: "videos", title: { message: "Video grid" } }, + { name: "images", title: { message: "Image grid" } }, + { name: "reviews", title: { message: "Reviews" } }, + { name: "cast", title: { message: "Cast grid" } }, + { name: "related", title: { message: "Related titles" } } +]; +function ContentSettingsTitlePagePanel() { + const { getValues, setValue } = useFormContext(); + const getSavedValue = () => { + return getValues("client.title_page.sections") || []; + }; + const [items, setItems] = useState(() => { + const savedValue = getSavedValue(); + const sortFn = (x) => savedValue.includes(x) ? savedValue.indexOf(x) : savedValue.length; + return [...defaultItems].sort((a, b) => sortFn(a.name) - sortFn(b.name)); + }); + return /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsxs("div", { className: "mb-14 text-sm", children: [ + /* @__PURE__ */ jsx(Trans, { message: "Title page sections" }), + /* @__PURE__ */ jsx("div", { className: "text-xs text-muted", children: /* @__PURE__ */ jsx(Trans, { message: "Select which sections should appear on title page and in which order." }) }) + ] }), + items.map((section, index) => /* @__PURE__ */ jsx( + ListItemLayout, + { + items, + isFirst: index === 0, + section, + title: /* @__PURE__ */ jsx(Trans, { ...section.title }), + onToggle: (section2, checked) => { + const savedValue = getSavedValue(); + const newValue = checked ? [...savedValue, section2.name] : savedValue.filter((x) => x !== section2.name); + setValue("client.title_page.sections", newValue); + }, + onSortEnd: (oldIndex, newIndex) => { + const sortedItems = moveItemInNewArray(items, oldIndex, newIndex); + setItems(sortedItems); + const savedValue = getSavedValue(); + const newValue = sortedItems.filter((x) => savedValue.includes(x.name)).map((x) => x.name); + setValue("client.title_page.sections", newValue); + } + }, + section.name + )) + ] }); +} +function ListItemLayout({ + isFirst, + title, + items, + section, + onSortEnd, + onToggle +}) { + const ref = useRef(null); + const previewRef = useRef(null); + const { watch } = useFormContext(); + const savedValue = watch("client.title_page.sections") || []; + const isChecked = savedValue.includes(section.name); + const { sortableProps, dragHandleRef } = useSortable({ + ref, + item: section, + items, + type: "titlePageSections", + preview: previewRef, + strategy: "line", + onSortEnd + }); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs( + "div", + { + className: clsx( + "flex w-full items-center gap-8 border-b py-6", + isFirst && "border-t border-t-transparent" + ), + ref, + ...sortableProps, + children: [ + /* @__PURE__ */ jsx(IconButton, { ref: dragHandleRef, children: /* @__PURE__ */ jsx(DragHandleIcon, {}) }), + /* @__PURE__ */ jsx("div", { className: "flex-auto", children: /* @__PURE__ */ jsx("div", { className: "text-sm", children: title }) }), + /* @__PURE__ */ jsx( + Checkbox, + { + checked: isChecked, + onChange: () => { + onToggle(section, !isChecked); + } + } + ) + ] + } + ), + /* @__PURE__ */ jsx(TabDragPreview, { title, ref: previewRef }) + ] }); +} +const TabDragPreview = React.forwardRef( + ({ title }, ref) => { + return /* @__PURE__ */ jsx(DragPreview, { ref, children: () => /* @__PURE__ */ jsx("div", { className: "rounded bg-chip p-8 text-sm shadow", children: title }) }); + } +); +function ContentSettings() { + return /* @__PURE__ */ jsx( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Content" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Control how content is displayed across the site." }), + children: /* @__PURE__ */ jsxs(Tabs, { children: [ + /* @__PURE__ */ jsxs(TabList, { children: [ + /* @__PURE__ */ jsx(Tab, { width: "min-w-132", children: /* @__PURE__ */ jsx(Trans, { message: "General" }) }), + /* @__PURE__ */ jsx(Tab, { width: "min-w-132", children: /* @__PURE__ */ jsx(Trans, { message: "Automation" }) }), + /* @__PURE__ */ jsx(Tab, { width: "min-w-132", children: /* @__PURE__ */ jsx(Trans, { message: "Title page" }) }) + ] }), + /* @__PURE__ */ jsxs(TabPanels, { className: "pt-24", children: [ + /* @__PURE__ */ jsx(TabPanel, { children: /* @__PURE__ */ jsx(ContentSettingsGeneralPanel, {}) }), + /* @__PURE__ */ jsx(TabPanel, { children: /* @__PURE__ */ jsx(ContentSettingsAutomationPanel, {}) }), + /* @__PURE__ */ jsx(TabPanel, { children: /* @__PURE__ */ jsx(ContentSettingsTitlePagePanel, {}) }) + ] }) + ] }) + } + ); +} +function useSearchModels() { + return useQuery({ + queryKey: ["search-models"], + queryFn: () => fetchModels() + }); +} +function fetchModels() { + return apiClient.get("admin/search/models").then((response) => response.data); +} +function useImportSearchModels() { + const { trans } = useTrans(); + return useMutation({ + mutationFn: (payload) => importModels(payload), + onSuccess: () => { + toast(trans(message("Imported search models"))); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function importModels(payload) { + return apiClient.post("admin/search/import", payload).then((r) => r.data); +} +function SearchSettings() { + return /* @__PURE__ */ jsxs( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Search" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Configure search method used on the site as well as related 3rd party integrations." }), + children: [ + /* @__PURE__ */ jsx(SearchMethodSelect, {}), + /* @__PURE__ */ jsx(ImportRecordsPanel, {}) + ] + } + ); +} +function SearchMethodSelect() { + const { watch } = useFormContext(); + const selectedMethod = watch("server.scout_driver"); + return /* @__PURE__ */ jsx(SettingsErrorGroup, { name: "search_group", separatorBottom: false, children: (isInvalid) => /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs( + FormSelect, + { + invalid: isInvalid, + name: "server.scout_driver", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Search method" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Which method should be used for search related functionality across the site." }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "mysql", children: "Mysql" }), + /* @__PURE__ */ jsx(Item, { value: "meilisearch", children: "Meilisearch" }), + /* @__PURE__ */ jsx(Item, { value: "tntsearch", children: "TNTSearch" }), + /* @__PURE__ */ jsx(Item, { value: "Matchish\\ScoutElasticSearch\\Engines\\ElasticSearchEngine", children: "Elasticsearch" }), + /* @__PURE__ */ jsx(Item, { value: "algolia", children: "Algolia" }) + ] + } + ), + selectedMethod === "mysql" && /* @__PURE__ */ jsx(MysqlFields, {}), + selectedMethod === "meilisearch" && /* @__PURE__ */ jsx(MeilisearchFields, {}), + selectedMethod === "algolia" && /* @__PURE__ */ jsx(AlgoliaFields, {}), + selectedMethod === "Matchish\\ScoutElasticSearch\\Engines\\ElasticSearchEngine" && /* @__PURE__ */ jsx(ElasticsearchField, {}) + ] }) }); +} +function MysqlFields() { + const { clearErrors } = useFormContext(); + return /* @__PURE__ */ jsxs( + FormSelect, + { + className: "mt-24", + name: "server.scout_mysql_mode", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "MySQL mode" }), + onSelectionChange: () => { + clearErrors(); + }, + children: [ + /* @__PURE__ */ jsx(Item, { value: "basic", children: /* @__PURE__ */ jsx(Trans, { message: "Basic" }) }), + /* @__PURE__ */ jsx(Item, { value: "extended", children: /* @__PURE__ */ jsx(Trans, { message: "Extended" }) }), + /* @__PURE__ */ jsx(Item, { value: "fulltext", children: /* @__PURE__ */ jsx(Trans, { message: "Fulltext" }) }) + ] + } + ); +} +function MeilisearchFields() { + return /* @__PURE__ */ jsx( + SectionHelper, + { + className: "mt-24", + color: "warning", + title: /* @__PURE__ */ jsx(Trans, { message: "Important!" }), + description: /* @__PURE__ */ jsx( + Trans, + { + message: "Meilisearch needs to be installed and running for this method to work.", + values: { + a: (parts) => /* @__PURE__ */ jsx( + "a", + { + href: "https://www.meilisearch.com", + target: "_blank", + rel: "noreferrer", + children: parts + } + ) + } + } + ) + } + ); +} +function ElasticsearchField() { + return /* @__PURE__ */ jsx( + SectionHelper, + { + className: "mt-24", + color: "warning", + title: /* @__PURE__ */ jsx(Trans, { message: "Important!" }), + description: /* @__PURE__ */ jsx( + Trans, + { + message: "Elasticsearch needs to be installed and running for this method to work.", + values: { + a: (parts) => /* @__PURE__ */ jsx("a", { href: "https://www.elastic.co", target: "_blank", rel: "noreferrer", children: parts }) + } + } + ) + } + ); +} +function AlgoliaFields() { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mt-24", + name: "server.algolia_app_id", + label: /* @__PURE__ */ jsx(Trans, { message: "Algolia app ID" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mt-24", + name: "server.algolia_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "Algolia app secret" }), + required: true + } + ) + ] }); +} +function ImportRecordsPanel() { + const { getValues } = useFormContext(); + const { data } = useSearchModels(); + const importModels2 = useImportSearchModels(); + const [selectedModel, setSelectedModel] = useState("*"); + return /* @__PURE__ */ jsx( + SectionHelper, + { + className: "mt-34", + color: "neutral", + title: /* @__PURE__ */ jsx(Trans, { message: "Import records" }), + description: /* @__PURE__ */ jsxs("span", { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Whenever a new search method is enabled, records that already exist in database need to be imported into the index. All records created after search method is enabled will be imported automatically." }), + /* @__PURE__ */ jsx("br", {}), + /* @__PURE__ */ jsx("br", {}), + /* @__PURE__ */ jsx(Trans, { message: "Depending on number of records in database, importing could take some time. Don't close this window while it is in progress." }) + ] }), + actions: /* @__PURE__ */ jsxs("div", { className: "mt-10 border-t pt-14", children: [ + /* @__PURE__ */ jsxs( + SelectForwardRef, + { + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "What to import?" }), + selectedValue: selectedModel, + onSelectionChange: (newValue) => { + setSelectedModel(newValue); + }, + children: [ + /* @__PURE__ */ jsx(Item, { value: "*", children: /* @__PURE__ */ jsx(Trans, { message: "Everything" }) }), + data == null ? void 0 : data.models.map((item) => /* @__PURE__ */ jsx(Item, { value: item.model, children: /* @__PURE__ */ jsx(Trans, { message: item.name }) }, item.model)) + ] + } + ), + /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + className: "mb-8 mt-24", + disabled: importModels2.isPending, + onClick: () => { + importModels2.mutate({ + model: selectedModel, + driver: getValues("server.scout_driver") + }); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Import now" }) + } + ) + ] }) + } + ); +} +const AppSettingsRoutes = [ + { + path: "search", + element: /* @__PURE__ */ jsx(SearchSettings, {}) + }, + { + path: "videos", + element: /* @__PURE__ */ jsx(VideoSettings, {}) + }, + { + path: "content", + element: /* @__PURE__ */ jsx(ContentSettings, {}) + } +]; +function SubscriptionSettings() { + const { trans } = useTrans(); + return /* @__PURE__ */ jsx( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Subscriptions" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Configure gateway integration, accepted cards, invoices and other related settings." }), + children: /* @__PURE__ */ jsxs(Tabs, { children: [ + /* @__PURE__ */ jsxs(TabList, { children: [ + /* @__PURE__ */ jsx(Tab, { children: /* @__PURE__ */ jsx(Trans, { message: "General" }) }), + /* @__PURE__ */ jsx(Tab, { children: /* @__PURE__ */ jsx(Trans, { message: "Invoices" }) }) + ] }), + /* @__PURE__ */ jsxs(TabPanels, { className: "pt-30", children: [ + /* @__PURE__ */ jsxs(TabPanel, { children: [ + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "client.billing.enable", + description: /* @__PURE__ */ jsx(Trans, { message: "Enable or disable all subscription related functionality across the site." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Enable subscriptions" }) + } + ), + /* @__PURE__ */ jsx(SettingsSeparator, {}), + /* @__PURE__ */ jsx(PaypalSection, {}), + /* @__PURE__ */ jsx(StripeSection, {}), + /* @__PURE__ */ jsx(SettingsSeparator, {}), + /* @__PURE__ */ jsx( + JsonChipField, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Accepted cards" }), + name: "client.billing.accepted_cards", + placeholder: trans({ message: "Add new card..." }) + } + ) + ] }), + /* @__PURE__ */ jsxs(TabPanel, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + inputElementType: "textarea", + rows: 5, + label: /* @__PURE__ */ jsx(Trans, { message: "Invoice address" }), + name: "client.billing.invoice.address", + className: "mb-30" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + inputElementType: "textarea", + rows: 5, + label: /* @__PURE__ */ jsx(Trans, { message: "Invoice notes" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Default notes to show under `notes` section of user invoice. Optional." }), + name: "client.billing.invoice.notes" + } + ) + ] }) + ] }) + ] }) + } + ); +} +function PaypalSection() { + const { watch } = useFormContext(); + const paypalIsEnabled = watch("client.billing.paypal.enable"); + return /* @__PURE__ */ jsxs("div", { className: "mb-30", children: [ + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "client.billing.paypal.enable", + description: /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Enable PayPal payment gateway integration." }), + /* @__PURE__ */ jsx( + LearnMoreLink, + { + className: "mt-6", + link: "https://support.vebto.com/hc/articles/147/configuring-paypal" + } + ) + ] }), + children: /* @__PURE__ */ jsx(Trans, { message: "PayPal gateway" }) + } + ), + paypalIsEnabled ? /* @__PURE__ */ jsx(SettingsErrorGroup, { name: "paypal_group", children: (isInvalid) => /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "server.paypal_client_id", + label: /* @__PURE__ */ jsx(Trans, { message: "PayPal Client ID" }), + required: true, + invalid: isInvalid, + className: "mb-20" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "server.paypal_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "PayPal Secret" }), + required: true, + invalid: isInvalid, + className: "mb-20" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "server.paypal_webhook_id", + label: /* @__PURE__ */ jsx(Trans, { message: "PayPal Webhook ID" }), + required: true, + invalid: isInvalid, + className: "mb-20" + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "client.billing.paypal_test_mode", + invalid: isInvalid, + description: /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Trans, { message: "Allows testing PayPal payments with sandbox accounts." }) }), + children: /* @__PURE__ */ jsx(Trans, { message: "PayPal test mode" }) + } + ) + ] }) }) : null + ] }); +} +function StripeSection() { + const { watch } = useFormContext(); + const stripeEnabled = watch("client.billing.stripe.enable"); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "client.billing.stripe.enable", + description: /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Enable Stripe payment gateway integration." }), + /* @__PURE__ */ jsx( + LearnMoreLink, + { + className: "mt-6", + link: "https://support.vebto.com/hc/articles/148/configuring-stripe" + } + ) + ] }), + children: /* @__PURE__ */ jsx(Trans, { message: "Stripe gateway" }) + } + ), + stripeEnabled ? /* @__PURE__ */ jsx(SettingsErrorGroup, { name: "stripe_group", separatorBottom: false, children: (isInvalid) => /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "server.stripe_key", + label: /* @__PURE__ */ jsx(Trans, { message: "Stripe publishable key" }), + required: true, + className: "mb-20", + invalid: isInvalid + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "server.stripe_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "Stripe secret key" }), + required: true, + className: "mb-20", + invalid: isInvalid + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "server.stripe_webhook_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "Stripe webhook signing secret" }), + className: "mb-20", + invalid: isInvalid + } + ) + ] }) }) : null + ] }); +} +function LocalizationSettings() { + const { data } = useValueLists(["timezones", "localizations"]); + const today = useCurrentDateTime(); + const { trans } = useTrans(); + return /* @__PURE__ */ jsxs( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Localization" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Configure global date, time and language settings." }), + children: [ + /* @__PURE__ */ jsxs( + FormSelect, + { + className: "mb-30", + required: true, + name: "client.dates.default_timezone", + showSearchField: true, + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Default timezone" }), + searchPlaceholder: trans(message("Search timezones")), + description: /* @__PURE__ */ jsx(Trans, { message: "Which timezone should be selected by default for new users and guests." }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "auto", children: /* @__PURE__ */ jsx(Trans, { message: "Auto" }) }, "auto"), + Object.entries((data == null ? void 0 : data.timezones) || {}).map(([groupName, timezones]) => /* @__PURE__ */ jsx(Section, { label: groupName, children: timezones.map((timezone) => /* @__PURE__ */ jsx(Item, { value: timezone.value, children: timezone.text }, timezone.value)) }, groupName)) + ] + } + ), + /* @__PURE__ */ jsxs( + FormSelect, + { + name: "client.locale.default", + className: "mb-30", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Default language" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Which localization should be selected by default for new users and guests." }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "auto", children: /* @__PURE__ */ jsx(Trans, { message: "Auto" }) }, "auto"), + ((data == null ? void 0 : data.localizations) || []).map((locale) => /* @__PURE__ */ jsx(Item, { value: locale.language, capitalizeFirst: true, children: locale.name }, locale.language)) + ] + } + ), + /* @__PURE__ */ jsxs( + FormRadioGroup, + { + required: true, + className: "mb-30", + size: "sm", + name: "client.dates.format", + orientation: "vertical", + label: /* @__PURE__ */ jsx(Trans, { message: "Date verbosity" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Default verbosity for all dates displayed across the site. Month/day order and separators will be adjusted automatically, based on user's locale." }), + children: [ + /* @__PURE__ */ jsx(FormRadio, { value: "auto", children: /* @__PURE__ */ jsx(Trans, { message: "Auto" }) }, "auto"), + Object.entries(DateFormatPresets).map(([format, options]) => /* @__PURE__ */ jsx(FormRadio, { value: format, children: /* @__PURE__ */ jsx(FormattedDate, { date: today, options }) }, format)) + ] + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "client.i18n.enable", + description: /* @__PURE__ */ jsx(Trans, { message: "If disabled, site will always be shown in default language and user will not be able to change their locale." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Enable translations" }) + } + ) + ] + } + ); +} +function AuthenticationSettings() { + return /* @__PURE__ */ jsxs( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Authentication" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Configure registration, social login and related 3rd party integrations." }), + children: [ + /* @__PURE__ */ jsx(EmailConfirmationSection, {}), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-24", + name: "client.registration.disable", + description: /* @__PURE__ */ jsx(Trans, { message: "All registration related functionality (including social login) will be disabled." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Disable registration" }) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-24", + name: "client.single_device_login", + description: /* @__PURE__ */ jsx(Trans, { message: "Only allow one device to be logged into user account at the same time." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Single device login" }) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "client.social.compact_buttons", + description: /* @__PURE__ */ jsx(Trans, { message: "Use compact design for social login buttons." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Compact buttons" }) + } + ), + /* @__PURE__ */ jsx(EnvatoSection, {}), + /* @__PURE__ */ jsx(GoogleSection, {}), + /* @__PURE__ */ jsx(FacebookSection, {}), + /* @__PURE__ */ jsx(TwitterSection, {}), + /* @__PURE__ */ jsx(SettingsSeparator, {}), + /* @__PURE__ */ jsx( + FormTextField, + { + inputElementType: "textarea", + rows: 3, + className: "mt-24", + name: "client.auth.domain_blacklist", + label: /* @__PURE__ */ jsx(Trans, { message: "Domain blacklist" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Comma separated list of domains. Users will not be able to register or login using any email adress from specified domains." }) + } + ) + ] + } + ); +} +function MailNotSetupWarning() { + const { watch } = useFormContext(); + const mailSetup = watch("server.mail_setup"); + if (mailSetup) + return null; + return /* @__PURE__ */ jsx("p", { className: "mt-10 rounded-panel border p-10 text-sm text-danger", children: /* @__PURE__ */ jsx( + Trans, + { + message: "Outgoing mail method needs to be setup before enabling this setting. Fix now", + values: { + a: (text) => /* @__PURE__ */ jsx( + Button, + { + elementType: Link, + variant: "outline", + size: "xs", + display: "flex", + className: "mt-10 max-w-max", + to: "/admin/settings/outgoing-email", + children: text + } + ) + } + } + ) }); +} +function EmailConfirmationSection() { + return /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-30", + name: "client.require_email_confirmation", + description: /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Require newly registered users to validate their email address before being able to login." }), + /* @__PURE__ */ jsx(MailNotSetupWarning, {}) + ] }), + children: /* @__PURE__ */ jsx(Trans, { message: "Require email confirmation" }) + } + ); +} +function EnvatoSection() { + var _a2; + const { watch } = useFormContext(); + const settings = useSettings(); + const envatoLoginEnabled = watch("client.social.envato.enable"); + if (!((_a2 = settings.envato) == null ? void 0 : _a2.enable)) + return null; + return /* @__PURE__ */ jsx(SettingsErrorGroup, { separatorBottom: false, name: "envato_group", children: (isInvalid) => /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormSwitch, + { + invalid: isInvalid, + name: "client.social.envato.enable", + description: /* @__PURE__ */ jsx(Trans, { message: "Enable logging into the site via envato." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Envato login" }) + } + ), + !!envatoLoginEnabled && /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mt-30", + name: "server.envato_id", + label: /* @__PURE__ */ jsx(Trans, { message: "Envato ID" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mt-30", + name: "server.envato_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "Envato secret" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mt-30", + name: "server.envato_personal_token", + label: /* @__PURE__ */ jsx(Trans, { message: "Envato personal token" }), + required: true + } + ) + ] }) + ] }) }); +} +function GoogleSection() { + const { watch } = useFormContext(); + const googleLoginEnabled = watch("client.social.google.enable"); + return /* @__PURE__ */ jsx(SettingsErrorGroup, { name: "google_group", children: (isInvalid) => /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormSwitch, + { + invalid: isInvalid, + name: "client.social.google.enable", + description: /* @__PURE__ */ jsx(Trans, { message: "Enable logging into the site via google." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Google login" }) + } + ), + !!googleLoginEnabled && /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mt-30", + name: "server.google_id", + label: /* @__PURE__ */ jsx(Trans, { message: "Google client ID" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mt-30", + name: "server.google_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "Google client secret" }), + required: true + } + ) + ] }) + ] }) }); +} +function FacebookSection() { + const { watch } = useFormContext(); + const facebookLoginEnabled = watch("client.social.facebook.enable"); + return /* @__PURE__ */ jsx(SettingsErrorGroup, { name: "facebook_group", separatorTop: false, children: (isInvalid) => /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormSwitch, + { + invalid: isInvalid, + name: "client.social.facebook.enable", + description: /* @__PURE__ */ jsx(Trans, { message: "Enable logging into the site via facebook." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Facebook login" }) + } + ), + !!facebookLoginEnabled && /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mt-30", + name: "server.facebook_id", + label: /* @__PURE__ */ jsx(Trans, { message: "Facebook app ID" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mt-30", + name: "server.facebook_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "Facebook app secret" }), + required: true + } + ) + ] }) + ] }) }); +} +function TwitterSection() { + const { watch } = useFormContext(); + const twitterLoginEnabled = watch("client.social.twitter.enable"); + return /* @__PURE__ */ jsx( + SettingsErrorGroup, + { + name: "twitter_group", + separatorTop: false, + separatorBottom: false, + children: (isInvalid) => /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormSwitch, + { + invalid: isInvalid, + name: "client.social.twitter.enable", + description: /* @__PURE__ */ jsx(Trans, { message: "Enable logging into the site via twitter." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Twitter login" }) + } + ), + !!twitterLoginEnabled && /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mt-30", + name: "server.twitter_id", + label: /* @__PURE__ */ jsx(Trans, { message: "Twitter ID" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mt-30", + name: "server.twitter_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "Twitter secret" }), + required: true + } + ) + ] }) + ] }) + } + ); +} +function fetchMaxServerUploadSize() { + return apiClient.get("uploads/server-max-file-size").then((response) => response.data); +} +function useMaxServerUploadSize() { + return useQuery({ + queryKey: ["MaxServerUploadSize"], + queryFn: () => fetchMaxServerUploadSize() + }); +} +const spaceUnits = ["B", "KB", "MB", "GB", "TB", "PB"]; +function convertToBytes(value, unit) { + if (value == null) + return 0; + switch (unit) { + case "KB": + return value * 1024; + case "MB": + return value * 1024 ** 2; + case "GB": + return value * 1024 ** 3; + case "TB": + return value * 1024 ** 4; + case "PB": + return value * 1024 ** 5; + default: + return value; + } +} +const MaxValue = 108851651149824; +const FormFileSizeField = React.forwardRef(({ name, ...props }, ref) => { + const { + field: { + onChange: setByteValue, + onBlur, + value: byteValue = "", + ref: inputRef + }, + fieldState: { invalid, error } + } = useController({ + name + }); + const [liveValue, setLiveValue] = useState(""); + const [unit, setUnit] = useState("MB"); + useEffect(() => { + if (byteValue == null || byteValue === "") { + setLiveValue(""); + return; + } + const { amount, unit: newUnit } = fromBytes({ + bytes: Math.min(byteValue, MaxValue) + }); + setUnit(newUnit || "MB"); + setLiveValue(Number.isNaN(amount) ? "" : amount); + }, [byteValue, unit]); + const formProps = { + onChange: (e) => { + const value = parseInt(e.target.value); + if (Number.isNaN(value)) { + setByteValue(value); + } else { + const newBytes = convertToBytes( + parseInt(e.target.value), + unit + ); + setByteValue(newBytes); + } + }, + onBlur, + value: liveValue, + invalid, + errorMessage: error == null ? void 0 : error.message, + inputRef + }; + const unitSelect = /* @__PURE__ */ jsx( + SelectForwardRef, + { + minWidth: "min-w-80", + selectionMode: "single", + selectedValue: unit, + disabled: !byteValue, + onSelectionChange: (newUnit) => { + const newBytes = convertToBytes( + liveValue || 0, + newUnit + ); + setByteValue(newBytes); + }, + children: spaceUnits.slice(0, 5).map((u) => /* @__PURE__ */ jsx(Item, { value: u, children: u === "B" ? "Bytes" : u }, u)) + } + ); + return /* @__PURE__ */ jsx( + TextField, + { + ...mergeProps(formProps, props), + type: "number", + ref, + endAppend: unitSelect + } + ); +}); +const fromBytes = memoize( + ({ bytes }) => { + const pretty = prettyBytes(bytes); + if (!pretty) + return { amount: "", unit: "MB" }; + let amount = parseInt(pretty.split(" ")[0]); + amount = Math.round(amount); + return { amount, unit: pretty.split(" ")[1] }; + } +); +function useUploadS3Cors() { + const { trans } = useTrans(); + return useMutation({ + mutationFn: () => uploadCors(), + onSuccess: () => { + toast(trans(message("CORS file updated"))); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function uploadCors() { + return apiClient.post("s3/cors/upload").then((r) => r.data); +} +function useGenerateDropboxRefreshToken() { + return useMutation({ + mutationFn: (props) => generateToken(props), + onError: (err) => showHttpErrorToast(err) + }); +} +function generateToken(payload) { + return apiClient.post("settings/uploading/dropbox-refresh-token", payload).then((r) => r.data); +} +function DropboxForm({ isInvalid }) { + const { watch, setValue } = useFormContext(); + const appKey = watch("server.storage_dropbox_app_key"); + const appSecret = watch("server.storage_dropbox_app_secret"); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-20", + name: "server.storage_dropbox_app_key", + label: /* @__PURE__ */ jsx(Trans, { message: "Dropbox application key" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-20", + name: "server.storage_dropbox_app_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "Dropbox application secret" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-20", + name: "server.storage_dropbox_refresh_token", + label: /* @__PURE__ */ jsx(Trans, { message: "Dropbox refresh token" }), + required: true + } + ), + /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (refreshToken) => { + if (refreshToken) { + setValue("server.storage_dropbox_refresh_token", refreshToken); + } + }, + children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "primary", + size: "xs", + disabled: !appKey || !appSecret, + children: /* @__PURE__ */ jsx(Trans, { message: "Get dropbox refresh token" }) + } + ), + /* @__PURE__ */ jsx(DropboxRefreshTokenDialog, { appKey, appSecret }) + ] + } + ) + ] }); +} +function DropboxRefreshTokenDialog({ + appKey, + appSecret +}) { + const form = useForm(); + const { formId, close } = useDialogContext(); + const generateRefreshToken = useGenerateDropboxRefreshToken(); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Connected dropbox account" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs( + Form$1, + { + id: formId, + form, + onSubmit: (data) => { + generateRefreshToken.mutate( + { + app_key: appKey, + app_secret: appSecret, + access_code: data.accessCode + }, + { + onSuccess: (response) => { + close(response.refreshToken); + } + } + ); + }, + children: [ + /* @__PURE__ */ jsxs("div", { className: "mb-20 pb-20 border-b", children: [ + /* @__PURE__ */ jsx("div", { className: "text-muted text-sm mb-10", children: /* @__PURE__ */ jsx(Trans, { message: "Click the 'get access code' button to get dropbox access code, then paste it into the field below." }) }), + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "primary", + size: "xs", + elementType: "a", + target: "_blank", + href: `https://www.dropbox.com/oauth2/authorize?client_id=${appKey}&token_access_type=offline&response_type=code`, + children: /* @__PURE__ */ jsx(Trans, { message: "Get access code" }) + } + ) + ] }), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "accessCode", + label: /* @__PURE__ */ jsx(Trans, { message: "Dropbox access code" }), + required: true + } + ) + ] + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx( + Button, + { + onClick: () => { + close(); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) + } + ), + /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + form: formId, + type: "submit", + disabled: !appKey || !appSecret || generateRefreshToken.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Connect" }) + } + ) + ] }) + ] }); +} +function UploadingSettings() { + const { trans } = useTrans(); + return /* @__PURE__ */ jsxs( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Uploading" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Configure size and type of files that users are able to upload. This will affect all uploads across the site." }), + children: [ + /* @__PURE__ */ jsx(PrivateUploadSection, {}), + /* @__PURE__ */ jsx(PublicUploadSection, {}), + /* @__PURE__ */ jsx(CredentialsSection, {}), + /* @__PURE__ */ jsx(SettingsErrorGroup, { name: "static_delivery_group", children: (isInvalid) => /* @__PURE__ */ jsxs( + FormRadioGroup, + { + invalid: isInvalid, + size: "sm", + name: "server.static_file_delivery", + orientation: "vertical", + label: /* @__PURE__ */ jsx(Trans, { message: "File delivery optimization" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Both X-Sendfile and X-Accel need to be enabled on the server first. When enabled, it will reduce server memory and CPU usage when previewing or downloading files, especially for large files." }), + children: [ + /* @__PURE__ */ jsx(FormRadio, { value: "", children: /* @__PURE__ */ jsx(Trans, { message: "None" }) }), + /* @__PURE__ */ jsx(FormRadio, { value: "xsendfile", children: /* @__PURE__ */ jsx(Trans, { message: "X-Sendfile (Apache)" }) }), + /* @__PURE__ */ jsx(FormRadio, { value: "xaccel", children: /* @__PURE__ */ jsx(Trans, { message: "X-Accel (Nginx)" }) }) + ] + } + ) }), + /* @__PURE__ */ jsx( + FormFileSizeField, + { + className: "mb-30", + name: "client.uploads.chunk_size", + min: 1, + label: /* @__PURE__ */ jsx(Trans, { message: "Chunk size" }), + placeholder: "Infinity", + description: /* @__PURE__ */ jsx(Trans, { message: "Size (in bytes) for each file chunk. It should only be changed if there is a maximum upload size on your server or proxy (for example cloudflare). If chunk size is larger then limit on the server, uploads will fail." }) + } + ), + /* @__PURE__ */ jsx(MaxUploadSizeSection, {}), + /* @__PURE__ */ jsx(SettingsSeparator, {}), + /* @__PURE__ */ jsx( + FormFileSizeField, + { + min: 1, + name: "client.uploads.max_size", + className: "mb-30", + label: /* @__PURE__ */ jsx(Trans, { message: "Maximum file size" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Maximum size (in bytes) for a single file user can upload." }) + } + ), + /* @__PURE__ */ jsx( + FormFileSizeField, + { + min: 1, + name: "client.uploads.available_space", + className: "mb-30", + label: /* @__PURE__ */ jsx(Trans, { message: "Available space" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Disk space (in bytes) each user uploads are allowed to take up. This can be overridden per user." }) + } + ), + /* @__PURE__ */ jsx( + JsonChipField, + { + name: "client.uploads.allowed_extensions", + className: "mb-30", + label: /* @__PURE__ */ jsx(Trans, { message: "Allowed extensions" }), + placeholder: trans(message("Add extension...")), + description: /* @__PURE__ */ jsx(Trans, { message: "List of allowed file types (jpg, mp3, pdf etc.). Leave empty to allow all file types." }) + } + ), + /* @__PURE__ */ jsx( + JsonChipField, + { + name: "client.uploads.blocked_extensions", + label: /* @__PURE__ */ jsx(Trans, { message: "Blocked extensions" }), + placeholder: trans(message("Add extension...")), + description: /* @__PURE__ */ jsx(Trans, { message: "Prevent uploading of these file types, even if they are allowed above." }) + } + ) + ] + } + ); +} +function MaxUploadSizeSection() { + const { data } = useMaxServerUploadSize(); + return /* @__PURE__ */ jsx( + SectionHelper, + { + color: "warning", + description: /* @__PURE__ */ jsx( + Trans, + { + message: "Maximum upload size on your server currently is set to :size", + values: { size: data == null ? void 0 : data.maxSize, b: (chunks) => /* @__PURE__ */ jsx("b", { children: chunks }) } + } + ) + } + ); +} +function PrivateUploadSection() { + const { watch, clearErrors } = useFormContext(); + const isEnabled = watch("server.uploads_disk_driver"); + if (!isEnabled) + return null; + return /* @__PURE__ */ jsxs( + FormSelect, + { + className: "mb-30", + selectionMode: "single", + name: "server.uploads_disk_driver", + label: /* @__PURE__ */ jsx(Trans, { message: "User Uploads Storage Method" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Where should user private file uploads be stored." }), + onSelectionChange: () => { + clearErrors(); + }, + children: [ + /* @__PURE__ */ jsx(Item, { value: "local", children: /* @__PURE__ */ jsx(Trans, { message: "Local Disk (Default)" }) }), + /* @__PURE__ */ jsx(Item, { value: "ftp", children: "FTP" }), + /* @__PURE__ */ jsx(Item, { value: "digitalocean_s3", children: "DigitalOcean Spaces" }), + /* @__PURE__ */ jsx(Item, { value: "backblaze_s3", children: "Backblaze" }), + /* @__PURE__ */ jsx(Item, { value: "s3", children: "Amazon S3 (Or compatible service)" }), + /* @__PURE__ */ jsx(Item, { value: "dropbox", children: "Dropbox" }), + /* @__PURE__ */ jsx(Item, { value: "rackspace", children: "Rackspace" }) + ] + } + ); +} +function PublicUploadSection() { + const { watch, clearErrors } = useFormContext(); + const isEnabled = watch("server.public_disk_driver"); + if (!isEnabled) + return null; + return /* @__PURE__ */ jsxs( + FormSelect, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Public Uploads Storage Method" }), + selectionMode: "single", + name: "server.public_disk_driver", + description: /* @__PURE__ */ jsx(Trans, { message: "Where should user public uploads (like avatars) be stored." }), + onSelectionChange: () => { + clearErrors(); + }, + children: [ + /* @__PURE__ */ jsx(Item, { value: "local", children: /* @__PURE__ */ jsx(Trans, { message: "Local Disk (Default)" }) }), + /* @__PURE__ */ jsx(Item, { value: "s3", children: "Amazon S3" }), + /* @__PURE__ */ jsx(Item, { value: "ftp", children: "FTP" }), + /* @__PURE__ */ jsx(Item, { value: "digitalocean_s3", children: "DigitalOcean Spaces" }), + /* @__PURE__ */ jsx(Item, { value: "backblaze_s3", children: "Backblaze" }) + ] + } + ); +} +function CredentialsSection() { + const { watch } = useFormContext(); + const drives = [ + watch("server.uploads_disk_driver"), + watch("server.public_disk_driver") + ]; + if (drives[0] === "local" && drives[1] === "local") { + return null; + } + return /* @__PURE__ */ jsx(SettingsErrorGroup, { separatorBottom: false, name: "storage_group", children: (isInvalid) => { + if (drives.includes("s3")) { + return /* @__PURE__ */ jsx(S3Form, { isInvalid }); + } + if (drives.includes("ftp")) { + return /* @__PURE__ */ jsx(FtpForm, { isInvalid }); + } + if (drives.includes("dropbox")) { + return /* @__PURE__ */ jsx(DropboxForm, { isInvalid }); + } + if (drives.includes("digitalocean_s3")) { + return /* @__PURE__ */ jsx(DigitalOceanForm, { isInvalid }); + } + if (drives.includes("backblaze_s3")) { + return /* @__PURE__ */ jsx(BackblazeForm, { isInvalid }); + } + } }); +} +function S3Form({ isInvalid }) { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_s3_key", + label: /* @__PURE__ */ jsx(Trans, { message: "Amazon S3 key" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_s3_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "Amazon S3 secret" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_s3_region", + label: /* @__PURE__ */ jsx(Trans, { message: "Amazon S3 region" }), + pattern: "[a-z1-9\\-]+", + placeholder: "us-east-1" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_s3_bucket", + label: /* @__PURE__ */ jsx(Trans, { message: "Amazon S3 bucket" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + name: "server.storage_s3_endpoint", + label: /* @__PURE__ */ jsx(Trans, { message: "Amazon S3 endpoint" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Only change endpoint if you are using another S3 compatible storage service." }) + } + ), + /* @__PURE__ */ jsx(S3DirectUploadField, { invalid: isInvalid }) + ] }); +} +function DigitalOceanForm({ isInvalid }) { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_digitalocean_key", + label: /* @__PURE__ */ jsx(Trans, { message: "DigitalOcean key" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_digitalocean_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "DigitalOcean secret" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_digitalocean_region", + label: /* @__PURE__ */ jsx(Trans, { message: "DigitalOcean region" }), + pattern: "[a-z0-9\\-]+", + placeholder: "us-east-1", + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_digitalocean_bucket", + label: /* @__PURE__ */ jsx(Trans, { message: "DigitalOcean bucket" }), + required: true + } + ), + /* @__PURE__ */ jsx(S3DirectUploadField, { invalid: isInvalid }) + ] }); +} +function BackblazeForm({ isInvalid }) { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_backblaze_key", + label: /* @__PURE__ */ jsx(Trans, { message: "Backblaze KeyID" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_backblaze_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "Backblaze applicationKey" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_backblaze_region", + label: /* @__PURE__ */ jsx(Trans, { message: "Backblaze Region" }), + pattern: "[a-z0-9\\-]+", + placeholder: "us-west-002", + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_backblaze_bucket", + label: /* @__PURE__ */ jsx(Trans, { message: "Backblaze bucket name" }), + required: true + } + ), + /* @__PURE__ */ jsx(S3DirectUploadField, { invalid: isInvalid }) + ] }); +} +function S3DirectUploadField({ invalid }) { + var _a2, _b; + const uploadCors2 = useUploadS3Cors(); + const { data: defaultSettings } = useAdminSettings(); + const s3DriverEnabled = ((_a2 = defaultSettings == null ? void 0 : defaultSettings.server.uploads_disk_driver) == null ? void 0 : _a2.endsWith("s3")) || ((_b = defaultSettings == null ? void 0 : defaultSettings.server.public_disk_driver) == null ? void 0 : _b.endsWith("s3")); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mt-30", + invalid, + name: "client.uploads.s3_direct_upload", + description: /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx(Trans, { message: "Upload files directly from the browser to s3 without going through the server. It will save on server bandwidth and should result in faster upload times. This should be enabled, unless storage provider does not support multipart uploads." }) }), + /* @__PURE__ */ jsx("p", { className: "mt-10", children: /* @__PURE__ */ jsx(Trans, { message: "If s3 provider is not configured to allow uploads from browser, this can be done automatically via CORS button below, when valid credentials are saved." }) }) + ] }), + children: /* @__PURE__ */ jsx(Trans, { message: "Direct upload" }) + } + ), + /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + size: "xs", + className: "mt-20", + onClick: () => { + uploadCors2.mutate(); + }, + disabled: !s3DriverEnabled || uploadCors2.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Configure CORS" }) + } + ) + ] }); +} +function FtpForm({ isInvalid }) { + return /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_ftp_host", + label: /* @__PURE__ */ jsx(Trans, { message: "FTP hostname" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_ftp_username", + label: /* @__PURE__ */ jsx(Trans, { message: "FTP username" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_ftp_password", + label: /* @__PURE__ */ jsx(Trans, { message: "FTP password" }), + type: "password", + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_ftp_root", + label: /* @__PURE__ */ jsx(Trans, { message: "FTP directory" }), + placeholder: "/" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.storage_ftp_port", + label: /* @__PURE__ */ jsx(Trans, { message: "FTP port" }), + type: "number", + min: 0, + placeholder: "21" + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + invalid: isInvalid, + name: "server.storage_ftp_passive", + className: "mb-30", + children: /* @__PURE__ */ jsx(Trans, { message: "Passive" }) + } + ), + /* @__PURE__ */ jsx(FormSwitch, { invalid: isInvalid, name: "server.storage_ftp_ssl", children: /* @__PURE__ */ jsx(Trans, { message: "SSL" }) }) + ] }); +} +function MailgunCredentials({ isInvalid }) { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.mailgun_domain", + label: /* @__PURE__ */ jsx(Trans, { message: "Mailgun domain" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Usually the domain of your site (site.com)" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.mailgun_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "Mailgun API key" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Should start with `key-`" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + name: "server.mailgun_endpoint", + label: /* @__PURE__ */ jsx(Trans, { message: "Mailgun endpoint" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Can be left empty, if your mailgun account is in the US region." }), + placeholder: "api.eu.mailgun.net" + } + ) + ] }); +} +function SmtpCredentials({ isInvalid }) { + return /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.mail_host", + label: /* @__PURE__ */ jsx(Trans, { message: "SMTP host" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.mail_username", + label: /* @__PURE__ */ jsx(Trans, { message: "SMTP username" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + type: "password", + name: "server.mail_password", + label: /* @__PURE__ */ jsx(Trans, { message: "SMTP password" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + type: "number", + name: "server.mail_port", + label: /* @__PURE__ */ jsx(Trans, { message: "SMTP port" }) + } + ), + /* @__PURE__ */ jsxs( + FormSelect, + { + selectionMode: "single", + invalid: isInvalid, + className: "mb-30", + name: "server.mail_encryption", + label: /* @__PURE__ */ jsx(Trans, { message: "SMTP encryption" }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "", children: /* @__PURE__ */ jsx(Trans, { message: "None" }) }), + /* @__PURE__ */ jsx(Item, { value: "tls", children: /* @__PURE__ */ jsx(Trans, { message: "TLS" }) }) + ] + } + ) + ] }); +} +function SesCredentials({ isInvalid }) { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.ses_key", + label: /* @__PURE__ */ jsx(Trans, { message: "SES key" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.ses_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "SES secret" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + name: "server.ses_region", + label: /* @__PURE__ */ jsx(Trans, { message: "SES region" }), + placeholder: "us-east-1", + required: true + } + ) + ] }); +} +function PostmarkCredentials({ isInvalid }) { + return /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + name: "server.postmark_token", + label: /* @__PURE__ */ jsx(Trans, { message: "Postmark token" }), + required: true + } + ); +} +const GmailIcon = createSvgIcon( + [ + /* @__PURE__ */ jsx( + "path", + { + fill: "#4caf50", + d: "M45,16.2l-5,2.75l-5,4.75L35,40h7c1.657,0,3-1.343,3-3V16.2z" + }, + "0" + ), + /* @__PURE__ */ jsx( + "path", + { + fill: "#1e88e5", + d: "M3,16.2l3.614,1.71L13,23.7V40H6c-1.657,0-3-1.343-3-3V16.2z" + }, + "1" + ), + /* @__PURE__ */ jsx( + "polygon", + { + fill: "#e53935", + points: "35,11.2 24,19.45 13,11.2 12,17 13,23.7 24,31.95 35,23.7 36,17" + }, + "2" + ), + /* @__PURE__ */ jsx( + "path", + { + fill: "#c62828", + d: "M3,12.298V16.2l10,7.5V11.2L9.876,8.859C9.132,8.301,8.228,8,7.298,8h0C4.924,8,3,9.924,3,12.298z" + }, + "3" + ), + /* @__PURE__ */ jsx( + "path", + { + fill: "#fbc02d", + d: "M45,12.298V16.2l-10,7.5V11.2l3.124-2.341C38.868,8.301,39.772,8,40.702,8h0 C43.076,8,45,9.924,45,12.298z" + }, + "4" + ) + ], + "Gmail", + "0 0 48 48" +); +function ConnectGmailPanel() { + const { watch, setValue } = useFormContext(); + const { connectSocial } = useSocialLogin(); + const connectedEmail = watch("server.connectedGmailAccount"); + const handleGmailConnect = async () => { + const e = await connectSocial("secure/settings/mail/gmail/connect"); + if ((e == null ? void 0 : e.status) === "SUCCESS") { + const email = e.callbackData.profile.email; + setValue("server.connectedGmailAccount", email); + toast(message("Connected gmail account: :email", { values: { email } })); + } + }; + const connectButton = /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "primary", + startIcon: /* @__PURE__ */ jsx(GmailIcon, {}), + onClick: () => { + handleGmailConnect(); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Connect gmail account" }) + } + ); + const reconnectPanel = /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-14 rounded border bg-alt px-14 py-6 text-sm", children: [ + /* @__PURE__ */ jsx(GmailIcon, { size: "lg" }), + connectedEmail, + /* @__PURE__ */ jsx( + Button, + { + variant: "text", + color: "primary", + className: "ml-auto", + onClick: () => { + handleGmailConnect(); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Reconnect" }) + } + ) + ] }); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx("div", { className: "mb-6 text-sm", children: /* @__PURE__ */ jsx(Trans, { message: "Gmail account" }) }), + connectedEmail ? reconnectPanel : connectButton + ] }); +} +function OutgoingMailGroup() { + const { watch, clearErrors } = useFormContext(); + const selectedDriver = watch("server.mail_driver"); + const credentialForms = []; + if (selectedDriver === "mailgun") { + credentialForms.push(MailgunCredentials); + } + if (selectedDriver === "smtp") { + credentialForms.push(SmtpCredentials); + } + if (selectedDriver === "ses") { + credentialForms.push(SesCredentials); + } + if (selectedDriver === "postmark") { + credentialForms.push(PostmarkCredentials); + } + if (selectedDriver === "gmailApi") { + credentialForms.push(ConnectGmailPanel); + } + return /* @__PURE__ */ jsx( + SettingsErrorGroup, + { + separatorTop: false, + separatorBottom: false, + name: "mail_group", + children: (isInvalid) => /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs( + FormSelect, + { + onSelectionChange: () => { + clearErrors(); + }, + invalid: isInvalid, + selectionMode: "single", + name: "server.mail_driver", + label: /* @__PURE__ */ jsx(Trans, { message: "Outgoing mail method" }), + description: /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Which method should be used for sending outgoing application emails (like registration confirmation)" }), + /* @__PURE__ */ jsx( + LearnMoreLink, + { + className: "mt-8", + link: "https://support.vebto.com/hc/articles/42/44/155/incoming-emails" + } + ) + ] }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "mailgun", children: "Mailgun" }), + /* @__PURE__ */ jsx(Item, { value: "gmailApi", children: "Gmail Api" }), + /* @__PURE__ */ jsx(Item, { value: "smtp", children: "SMTP" }), + /* @__PURE__ */ jsx(Item, { value: "postmark", children: "Postmark" }), + /* @__PURE__ */ jsx(Item, { value: "ses", children: "Ses (Amazon Simple Email Service)" }), + /* @__PURE__ */ jsx(Item, { value: "sendmail", children: "SendMail" }), + /* @__PURE__ */ jsx(Item, { value: "log", children: "Log (Email will be saved to error log)" }) + ] + } + ), + credentialForms.length ? /* @__PURE__ */ jsx("div", { className: "mt-30", children: credentialForms.map((CredentialsForm, index) => /* @__PURE__ */ jsx(CredentialsForm, { isInvalid }, index)) }) : null + ] }) + } + ); +} +function OutgoingEmailSettings() { + return /* @__PURE__ */ jsxs( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Outgoing email settings" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Change outgoing email handlers, email credentials and other related settings." }), + children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + id: "outgoing-emails", + className: "mb-30", + type: "email", + name: "server.mail_from_address", + label: /* @__PURE__ */ jsx(Trans, { message: "From address" }), + description: /* @__PURE__ */ jsx(Trans, { message: "All outgoing application emails will be sent from this email address." }), + required: true + } + ), + /* @__PURE__ */ jsx(ContactAddressSection, {}), + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-30", + name: "server.mail_from_name", + label: /* @__PURE__ */ jsx(Trans, { message: "From name" }), + description: /* @__PURE__ */ jsx(Trans, { message: "All outgoing application emails will be sent using this name." }), + required: true + } + ), + /* @__PURE__ */ jsx( + SectionHelper, + { + color: "warning", + description: /* @__PURE__ */ jsx(Trans, { message: "Your selected mail method must be authorized to send emails using this address and name." }) + } + ), + /* @__PURE__ */ jsx(SettingsSeparator, {}), + /* @__PURE__ */ jsx(OutgoingMailGroup, {}) + ] + } + ); +} +function ContactAddressSection() { + const { base_url } = useSettings(); + const contactPageUrl = `${base_url}/contact`; + const link = /* @__PURE__ */ jsx(ExternalLink, { href: contactPageUrl, children: contactPageUrl }); + return /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-30", + type: "email", + name: "client.mail.contact_page_address", + label: /* @__PURE__ */ jsx(Trans, { message: "Contact page address" }), + description: /* @__PURE__ */ jsx( + Trans, + { + values: { + contactPageUrl: link + }, + message: "Where emails from :contactPageUrl page should be sent to." + } + ) + } + ); +} +function clearCache() { + return apiClient.post("cache/flush").then((r) => r.data); +} +function useClearCache() { + return useMutation({ + mutationFn: () => clearCache(), + onSuccess: () => { + toast(message("Cache cleared")); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function CacheSettings() { + const clearCache2 = useClearCache(); + return /* @__PURE__ */ jsxs( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Cache settings" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Select cache provider and manually clear cache." }), + children: [ + /* @__PURE__ */ jsx(CacheSelect, {}), + /* @__PURE__ */ jsx( + Button, + { + type: "button", + variant: "outline", + size: "xs", + color: "primary", + disabled: clearCache2.isPending, + onClick: () => { + clearCache2.mutate(); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Clear cache" }) + } + ), + /* @__PURE__ */ jsx( + SectionHelper, + { + color: "warning", + className: "mt-30", + description: /* @__PURE__ */ jsx( + Trans, + { + message: '"File" is the best option for most cases and should not be changed, unless you are familiar with another cache method and have it set up on the server already.' + } + ) + } + ) + ] + } + ); +} +function CacheSelect() { + const { watch, clearErrors } = useFormContext(); + const cacheDriver = watch("server.cache_driver"); + let CredentialSection = null; + if (cacheDriver === "memcached") { + CredentialSection = MemcachedCredentials; + } + return /* @__PURE__ */ jsx(SettingsErrorGroup, { separatorTop: false, name: "cache_group", children: (isInvalid) => { + return /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsxs( + FormSelect, + { + invalid: isInvalid, + onSelectionChange: () => { + clearErrors(); + }, + selectionMode: "single", + name: "server.cache_driver", + label: /* @__PURE__ */ jsx(Trans, { message: "Cache method" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Which method should be used for storing and retrieving cached items." }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "file", children: /* @__PURE__ */ jsx(Trans, { message: "File (Default)" }) }), + /* @__PURE__ */ jsx(Item, { value: "array", children: /* @__PURE__ */ jsx(Trans, { message: "None" }) }), + /* @__PURE__ */ jsx(Item, { value: "apc", children: "APC" }), + /* @__PURE__ */ jsx(Item, { value: "memcached", children: "Memcached" }), + /* @__PURE__ */ jsx(Item, { value: "redis", children: "Redis" }) + ] + } + ), + CredentialSection && /* @__PURE__ */ jsx("div", { className: "mt-30", children: /* @__PURE__ */ jsx(CredentialSection, { isInvalid }) }) + ] }); + } }); +} +function MemcachedCredentials({ isInvalid }) { + return /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.memcached_host", + label: /* @__PURE__ */ jsx(Trans, { message: "Memcached host" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + type: "number", + name: "server.memcached_port", + label: /* @__PURE__ */ jsx(Trans, { message: "Memcached port" }), + required: true + } + ) + ] }); +} +function LoggingSettings() { + return /* @__PURE__ */ jsxs( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Error logging" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Configure site error logging and related 3rd party integrations." }), + children: [ + /* @__PURE__ */ jsx(SentrySection, {}), + /* @__PURE__ */ jsx( + SectionHelper, + { + className: "mt-30", + color: "positive", + description: /* @__PURE__ */ jsx( + Trans, + { + values: { + a: (parts) => /* @__PURE__ */ jsx(ExternalLink, { href: "https://sentry.io", children: parts }) + }, + message: "Sentry integration provides real-time error tracking and helps identify and fix issues when site is in production." + } + ) + } + ) + ] + } + ); +} +function SentrySection() { + const { clearErrors } = useFormContext(); + return /* @__PURE__ */ jsx( + SettingsErrorGroup, + { + separatorTop: false, + separatorBottom: false, + name: "logging_group", + children: (isInvalid) => { + return /* @__PURE__ */ jsx( + FormTextField, + { + onChange: () => { + clearErrors(); + }, + invalid: isInvalid, + name: "server.sentry_dsn", + type: "url", + minLength: 30, + label: /* @__PURE__ */ jsx(Trans, { message: "Sentry DSN" }) + } + ); + } + } + ); +} +function QueueSettings() { + return /* @__PURE__ */ jsxs( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Queue" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Select active queue method and enter related 3rd party API keys." }), + children: [ + /* @__PURE__ */ jsx( + SectionHelper, + { + color: "positive", + className: "mb-30", + description: /* @__PURE__ */ jsx(Trans, { message: "Queues allow to defer time consuming tasks, such as sending an email, until a later time. Deferring these tasks can speed up web requests to the application." }) + } + ), + /* @__PURE__ */ jsx( + SectionHelper, + { + color: "warning", + className: "mb-30", + description: /* @__PURE__ */ jsx(Trans, { message: "All methods except sync require additional setup, which should be performed before changing the queue method. Consult documentation for more information." }) + } + ), + /* @__PURE__ */ jsx(DriverSection, {}) + ] + } + ); +} +function DriverSection() { + const { watch, clearErrors } = useFormContext(); + const queueDriver = watch("server.queue_driver"); + let CredentialSection = null; + if (queueDriver === "sqs") { + CredentialSection = SqsCredentials; + } + return /* @__PURE__ */ jsx( + SettingsErrorGroup, + { + separatorTop: false, + separatorBottom: false, + name: "queue_group", + children: (isInvalid) => { + return /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsxs( + FormSelect, + { + invalid: isInvalid, + onSelectionChange: () => { + clearErrors(); + }, + selectionMode: "single", + name: "server.queue_driver", + label: /* @__PURE__ */ jsx(Trans, { message: "Queue method" }), + required: true, + children: [ + /* @__PURE__ */ jsx(Item, { value: "sync", children: /* @__PURE__ */ jsx(Trans, { message: "Sync (Default)" }) }), + /* @__PURE__ */ jsx(Item, { value: "beanstalkd", children: "Beanstalkd" }), + /* @__PURE__ */ jsx(Item, { value: "database", children: /* @__PURE__ */ jsx(Trans, { message: "Database" }) }), + /* @__PURE__ */ jsx(Item, { value: "sqs", children: /* @__PURE__ */ jsx(Trans, { message: "SQS (Amazon simple queue service)" }) }), + /* @__PURE__ */ jsx(Item, { value: "redis", children: "Redis" }) + ] + } + ), + CredentialSection && /* @__PURE__ */ jsx("div", { className: "mt-30", children: /* @__PURE__ */ jsx(CredentialSection, { isInvalid }) }) + ] }); + } + } + ); +} +function SqsCredentials({ isInvalid }) { + return /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.sqs_queue_key", + label: /* @__PURE__ */ jsx(Trans, { message: "SQS queue key" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.sqs_queue_secret", + label: /* @__PURE__ */ jsx(Trans, { message: "SQS queue secret" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.sqs_queue_prefix", + label: /* @__PURE__ */ jsx(Trans, { message: "SQS queue prefix" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.sqs_queue_name", + label: /* @__PURE__ */ jsx(Trans, { message: "SQS queue name" }), + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + invalid: isInvalid, + className: "mb-30", + name: "server.sqs_queue_region", + label: /* @__PURE__ */ jsx(Trans, { message: "SQS queue region" }), + required: true + } + ) + ] }); +} +function RecaptchaSettings() { + const { settings } = useContext(SiteConfigContext); + return /* @__PURE__ */ jsxs( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Recaptcha" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Configure google recaptcha integration and credentials." }), + children: [ + (settings == null ? void 0 : settings.showRecaptchaLinkSwitch) && /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-30", + name: "client.recaptcha.enable.link_creation", + description: /* @__PURE__ */ jsx(Trans, { message: "Enable recaptcha integration when creating links from homepage or user dashboard." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Link creation" }) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-30", + name: "client.recaptcha.enable.contact", + description: /* @__PURE__ */ jsx( + Trans, + { + message: 'Enable recaptcha integration for "contact us" page.' + } + ), + children: /* @__PURE__ */ jsx(Trans, { message: "Contact page" }) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mb-30", + name: "client.recaptcha.enable.register", + description: /* @__PURE__ */ jsx(Trans, { message: "Enable recaptcha integration for registration page." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Registration page" }) + } + ), + /* @__PURE__ */ jsx(RecaptchaSection, {}) + ] + } + ); +} +function RecaptchaSection() { + const { clearErrors } = useFormContext(); + return /* @__PURE__ */ jsx( + SettingsErrorGroup, + { + separatorTop: false, + separatorBottom: false, + name: "recaptcha_group", + children: (isInvalid) => { + return /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-30", + onChange: () => { + clearErrors(); + }, + invalid: isInvalid, + name: "client.recaptcha.site_key", + label: /* @__PURE__ */ jsx(Trans, { message: "Recaptcha v3 site key" }) + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + onChange: () => { + clearErrors(); + }, + invalid: isInvalid, + name: "client.recaptcha.secret_key", + label: /* @__PURE__ */ jsx(Trans, { message: "Recaptcha v3 secret key" }) + } + ) + ] }); + } + } + ); +} +const FileField = React.forwardRef( + (props, ref) => { + const inputRef = useObjectRef(ref); + const { fieldProps, inputProps } = useField({ ...props, focusRef: inputRef }); + const inputFieldClassNames = getInputFieldClassNames(props); + return /* @__PURE__ */ jsx(Field, { ref, fieldClassNames: inputFieldClassNames, ...fieldProps, children: /* @__PURE__ */ jsx( + "input", + { + type: "file", + ref: inputRef, + ...inputProps, + className: clsx( + inputFieldClassNames.input, + "py-8", + "file:bg-primary file:text-on-primary file:border-none file:rounded file:text-sm file:font-semibold file:px-10 file:h-24 file:mr-10" + ) + } + ) }); + } +); +function FormFileField({ name, ...props }) { + const { + field: { onChange, onBlur, ref }, + fieldState: { invalid, error } + } = useController({ + name + }); + const [value, setValue] = React.useState(""); + const formProps = { + onChange: (e) => { + var _a2; + onChange((_a2 = e.target.files) == null ? void 0 : _a2[0]); + setValue(e.target.value); + }, + onBlur, + value, + invalid, + errorMessage: error == null ? void 0 : error.message + }; + return /* @__PURE__ */ jsx(FileField, { ref, ...mergeProps(formProps, props) }); +} +function ReportsSettings() { + return /* @__PURE__ */ jsx( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Analytics" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Configure google analytics integration and credentials." }), + children: /* @__PURE__ */ jsx(AnalyticsSection, {}) + } + ); +} +function AnalyticsSection() { + const { clearErrors } = useFormContext(); + return /* @__PURE__ */ jsx( + SettingsErrorGroup, + { + separatorTop: false, + separatorBottom: false, + name: "analytics_group", + children: (isInvalid) => /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormFileField, + { + className: "mb-30", + onChange: () => { + clearErrors(); + }, + invalid: isInvalid, + name: "files.certificate", + accept: ".json", + label: /* @__PURE__ */ jsx(Trans, { message: "Google service account key file (.json)" }) + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-30", + onChange: () => { + clearErrors(); + }, + invalid: isInvalid, + name: "server.analytics_property_id", + type: "number", + label: /* @__PURE__ */ jsx(Trans, { message: "Google analytics property ID" }) + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-30", + onChange: () => { + clearErrors(); + }, + invalid: isInvalid, + name: "client.analytics.tracking_code", + placeholder: "G-******", + min: "1", + max: "20", + description: /* @__PURE__ */ jsx(Trans, { message: "Google analytics measurement ID only, not the whole javascript snippet." }), + label: /* @__PURE__ */ jsx(Trans, { message: "Google tag manager measurement ID" }) + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "client.analytics.gchart_api_key", + label: /* @__PURE__ */ jsx(Trans, { message: "Google maps javascript API key" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Only required in order to show world geochart on integrated analytics pages." }) + } + ) + ] }) + } + ); +} +function useUpdateUser(form) { + const navigate = useNavigate$1(); + return useMutation({ + mutationFn: (props) => updateUser(props), + onSuccess: (response, props) => { + toast(message("User updated")); + queryClient.invalidateQueries({ queryKey: ["users"] }); + navigate("/admin/users"); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function updateUser({ id, ...other }) { + if (other.roles) { + other.roles = other.roles.map((r) => r.id); + } + return apiClient.put(`users/${id}`, other).then((r) => r.data); +} +function CrupdateUserForm({ + onSubmit, + form, + title, + subTitle, + isLoading, + avatarManager, + resendEmailButton, + children +}) { + const { require_email_confirmation } = useSettings(); + const { data: valueLists } = useValueLists(["roles", "permissions"]); + return /* @__PURE__ */ jsxs( + CrupdateResourceLayout, + { + onSubmit, + form, + title, + subTitle, + isLoading, + children: [ + /* @__PURE__ */ jsxs("div", { className: "mb-40 flex items-start gap-40 md:gap-80", children: [ + avatarManager, + /* @__PURE__ */ jsxs("div", { className: "flex-auto", children: [ + children, + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-30", + name: "first_name", + label: /* @__PURE__ */ jsx(Trans, { message: "First name" }) + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "last_name", + label: /* @__PURE__ */ jsx(Trans, { message: "Last name" }) + } + ) + ] }) + ] }), + /* @__PURE__ */ jsxs("div", { className: "mb-30 border-b border-t pb-30 pt-30", children: [ + /* @__PURE__ */ jsx( + FormSwitch, + { + className: clsx(resendEmailButton && "mb-30"), + disabled: !require_email_confirmation, + name: "email_verified_at", + description: /* @__PURE__ */ jsx(Trans, { message: "Whether email address has been confirmed. User will not be able to login until address is confirmed, unless confirmation is disabled from settings page." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Email confirmed" }) + } + ), + resendEmailButton + ] }), + /* @__PURE__ */ jsx( + FormFileSizeField, + { + className: "mb-30", + name: "available_space", + label: /* @__PURE__ */ jsx(Trans, { message: "Allowed storage space" }), + description: /* @__PURE__ */ jsx( + Trans, + { + values: { + a: (parts) => /* @__PURE__ */ jsx( + Link, + { + className: LinkStyle, + target: "_blank", + to: "/admin/settings/uploading", + children: parts + } + ) + }, + message: "Total storage space all user uploads are allowed to take up. If left empty, this value will be inherited from any roles or subscriptions user has, or from 'Available space' setting in Uploading settings page." + } + ) + } + ), + /* @__PURE__ */ jsx( + FormChipField, + { + className: "mb-30", + name: "roles", + label: /* @__PURE__ */ jsx(Trans, { message: "Roles" }), + suggestions: valueLists == null ? void 0 : valueLists.roles, + children: (chip) => /* @__PURE__ */ jsx(Item, { value: chip.id, children: chip.name }, chip.id) + } + ), + /* @__PURE__ */ jsxs("div", { className: "mt-30 border-t pt-30", children: [ + /* @__PURE__ */ jsx("div", { className: "mb-10 text-sm", children: /* @__PURE__ */ jsx(Trans, { message: "Permissions" }) }), + /* @__PURE__ */ jsx(FormPermissionSelector, { name: "permissions" }) + ] }) + ] + } + ); +} +const ReportIcon = createSvgIcon( + [/* @__PURE__ */ jsx("path", { d: "M15.73 3H8.27L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27L15.73 3zM19 14.9 14.9 19H9.1L5 14.9V9.1L9.1 5h5.8L19 9.1v5.8z" }, "0"), /* @__PURE__ */ jsx("circle", { cx: "12", cy: "16", r: "1" }, "1"), /* @__PURE__ */ jsx("path", { d: "M11 7h2v7h-2z" }, "2")], + "ReportOutlined" +); +function UpdateUserPage() { + var _a2, _b, _c; + const form = useForm(); + const { require_email_confirmation } = useSettings(); + const { userId } = useParams(); + const updateUser2 = useUpdateUser(form); + const resendConfirmationEmail = useResendVerificationEmail(); + const { data, isLoading } = useUser(userId, { + with: ["subscriptions", "roles", "permissions", "bans"] + }); + const banReason = (_b = (_a2 = data == null ? void 0 : data.user.bans) == null ? void 0 : _a2[0]) == null ? void 0 : _b.comment; + useEffect(() => { + if ((data == null ? void 0 : data.user) && !form.getValues().id) { + form.reset({ + first_name: data.user.first_name, + last_name: data.user.last_name, + roles: data.user.roles, + permissions: data.user.permissions, + id: data.user.id, + email_verified_at: Boolean(data.user.email_verified_at), + available_space: data.user.available_space, + avatar: data.user.avatar + }); + } + }, [data == null ? void 0 : data.user, form]); + if (isLoading) { + return /* @__PURE__ */ jsx(FullPageLoader, {}); + } + const resendEmailButton = /* @__PURE__ */ jsx( + Button, + { + size: "xs", + variant: "outline", + color: "primary", + disabled: !require_email_confirmation || resendConfirmationEmail.isPending || ((_c = data == null ? void 0 : data.user) == null ? void 0 : _c.email_verified_at) != null, + onClick: () => { + resendConfirmationEmail.mutate({ email: data.user.email }); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Resend email" }) + } + ); + return /* @__PURE__ */ jsx( + CrupdateUserForm, + { + onSubmit: (newValues) => { + updateUser2.mutate(newValues); + }, + form, + title: /* @__PURE__ */ jsx(Trans, { values: { email: data == null ? void 0 : data.user.email }, message: "Edit “:email“" }), + subTitle: banReason && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4 text-sm text-danger", children: [ + /* @__PURE__ */ jsx(ReportIcon, {}), + /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx( + Trans, + { + message: "Suspended: :reason", + values: { reason: banReason } + } + ) }) + ] }), + isLoading: updateUser2.isPending, + avatarManager: /* @__PURE__ */ jsx( + AvatarSection, + { + user: data.user, + onChange: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + } + } + ), + resendEmailButton, + children: /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-30", + name: "password", + type: "password", + label: /* @__PURE__ */ jsx(Trans, { message: "New password" }) + } + ) + } + ); +} +function AvatarSection({ user, onChange }) { + const uploadAvatar = useUploadAvatar({ user }); + const removeAvatar = useRemoveAvatar({ user }); + return /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsx( + FormImageSelector, + { + name: "avatar", + diskPrefix: "avatars", + variant: "avatar", + stretchPreview: true, + label: /* @__PURE__ */ jsx(Trans, { message: "Profile image" }), + previewSize: "w-90 h-90", + showRemoveButton: true, + onChange: (url) => { + if (url) { + uploadAvatar.mutate({ url }); + } else { + removeAvatar.mutate(); + } + onChange(); + } + } + ) }); +} +function useCreateUser(form) { + const navigate = useNavigate$1(); + return useMutation({ + mutationFn: (props) => createUser(props), + onSuccess: () => { + toast(message("User created")); + queryClient.invalidateQueries({ queryKey: DatatableDataQueryKey("users") }); + navigate("/admin/users"); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function createUser(payload) { + if (payload.roles) { + payload.roles = payload.roles.map((r) => r.id); + } + return apiClient.post("users", payload).then((r) => r.data); +} +function CreateUserPage() { + const form = useForm(); + const createUser2 = useCreateUser(form); + const avatarManager = /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsx( + FormImageSelector, + { + name: "avatar", + diskPrefix: "avatars", + variant: "avatar", + stretchPreview: true, + label: /* @__PURE__ */ jsx(Trans, { message: "Profile image" }), + previewSize: "w-90 h-90", + showRemoveButton: true + } + ) }); + return /* @__PURE__ */ jsxs( + CrupdateUserForm, + { + onSubmit: (newValues) => { + createUser2.mutate(newValues); + }, + form, + title: /* @__PURE__ */ jsx(Trans, { message: "Add new user" }), + isLoading: createUser2.isPending, + avatarManager, + children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-30", + name: "email", + type: "email", + label: /* @__PURE__ */ jsx(Trans, { message: "Email" }) + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-30", + name: "password", + type: "password", + label: /* @__PURE__ */ jsx(Trans, { message: "Password" }) + } + ) + ] + } + ); +} +const TranslateIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "m12.87 15.07-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7 1.62-4.33L19.12 17h-3.24z" }), + "TranslateOutlined" +); +const getLocalWithLinesQueryKey = (localeId) => { + const key = ["getLocaleWithLines"]; + if (localeId != null) { + key.push(localeId); + } + return key; +}; +function useLocaleWithLines(localeId) { + return useQuery({ + queryKey: getLocalWithLinesQueryKey(localeId), + queryFn: () => fetchLocaleWithLines(localeId), + staleTime: Infinity + }); +} +function fetchLocaleWithLines(localeId) { + return apiClient.get(`localizations/${localeId}`).then((response) => response.data); +} +function UpdateLocalization({ + id, + ...other +}) { + return apiClient.put(`localizations/${id}`, other).then((r) => r.data); +} +function useUpdateLocalization(form) { + return useMutation({ + mutationFn: (props) => UpdateLocalization(props), + onSuccess: () => { + toast(message("Localization updated")); + queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("localizations") + }); + queryClient.invalidateQueries({ queryKey: getLocalWithLinesQueryKey() }); + }, + onError: (r) => form ? onFormQueryError(r, form) : showHttpErrorToast(r) + }); +} +function UpdateLocalizationDialog({ + localization +}) { + const { trans } = useTrans(); + const { formId, close } = useDialogContext(); + const form = useForm({ + defaultValues: { + id: localization.id, + name: localization.name, + language: localization.language + } + }); + const { data } = useValueLists(["languages"]); + const languages = (data == null ? void 0 : data.languages) || []; + const updateLocalization = useUpdateLocalization(form); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Update localization" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs( + Form$1, + { + form, + id: formId, + onSubmit: (values) => { + updateLocalization.mutate(values, { onSuccess: close }); + }, + children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Name" }), + className: "mb-30", + required: true + } + ), + /* @__PURE__ */ jsx( + FormSelect, + { + required: true, + name: "language", + label: /* @__PURE__ */ jsx(Trans, { message: "Language" }), + selectionMode: "single", + showSearchField: true, + searchPlaceholder: trans(message("Search languages")), + children: languages.map((language) => /* @__PURE__ */ jsx(Item, { value: language.code, children: language.name }, language.code)) + } + ) + ] + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: close, children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + type: "submit", + form: formId, + disabled: updateLocalization.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ) + ] }) + ] }); +} +function createLocalization(payload) { + return apiClient.post(`localizations`, payload).then((r) => r.data); +} +function useCreateLocalization(form) { + const queryClient2 = useQueryClient(); + return useMutation({ + mutationFn: (props) => createLocalization(props), + onSuccess: () => { + toast(message("Localization created")); + queryClient2.invalidateQueries({ + queryKey: DatatableDataQueryKey("localizations") + }); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function CreateLocationDialog() { + const { trans } = useTrans(); + const { formId, close } = useDialogContext(); + const form = useForm({ + defaultValues: { + language: "en" + } + }); + const { data } = useValueLists(["languages"]); + const languages = (data == null ? void 0 : data.languages) || []; + const createLocalization2 = useCreateLocalization(form); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Create localization" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs( + Form$1, + { + form, + id: formId, + onSubmit: (values) => { + createLocalization2.mutate(values, { onSuccess: close }); + }, + children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + autoFocus: true, + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Name" }), + className: "mb-30", + required: true + } + ), + /* @__PURE__ */ jsx( + FormSelect, + { + required: true, + name: "language", + label: /* @__PURE__ */ jsx(Trans, { message: "Language" }), + selectionMode: "single", + showSearchField: true, + searchPlaceholder: trans(message("Search languages")), + children: languages.map((language) => /* @__PURE__ */ jsx(Item, { value: language.code, children: language.name }, language.code)) + } + ) + ] + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: close, children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + type: "submit", + form: formId, + disabled: createLocalization2.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ) + ] }) + ] }); +} +const aroundTheWorldSvg = "/assets/around-the-world-df9b11c5.svg"; +function useUploadTranslationFile() { + return useMutation({ + mutationFn: (payload) => uploadFile(payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("localizations") + }); + await queryClient.invalidateQueries({ + queryKey: getLocalWithLinesQueryKey() + }); + toast(message("Translation file uploaded")); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function uploadFile({ localeId, file }) { + const data = new FormData(); + data.append("file", file.native); + return apiClient.post(`localizations/${localeId}/upload`, data).then((r) => r.data); +} +const columnConfig$a = [ + { + key: "name", + allowsSorting: true, + sortingKey: "name", + visibleInMode: "all", + width: "flex-3 min-w-200", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Name" }), + body: (locale) => locale.name + }, + { + key: "language", + allowsSorting: true, + sortingKey: "language", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Language code" }), + body: (locale) => locale.language + }, + { + key: "updatedAt", + allowsSorting: true, + width: "w-100", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (locale) => /* @__PURE__ */ jsx(FormattedDate, { date: locale.updated_at }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + align: "end", + width: "w-84 flex-shrink-0", + visibleInMode: "all", + body: (locale) => { + return /* @__PURE__ */ jsxs("div", { className: "text-muted", children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Translate" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + elementType: Link, + to: `${locale.id}/translate`, + children: /* @__PURE__ */ jsx(TranslateIcon, {}) + } + ) }), + /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsx(RowActionsMenuTrigger, { locale }) }) + ] }); + } + } +]; +function LocalizationIndex() { + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "localizations", + title: /* @__PURE__ */ jsx(Trans, { message: "Localizations" }), + columns: columnConfig$a, + actions: /* @__PURE__ */ jsx(Actions$d, {}), + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: aroundTheWorldSvg, + title: /* @__PURE__ */ jsx(Trans, { message: "No localizations have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching localizations" }) + } + ) + } + ); +} +function Actions$d() { + return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(DataTableAddItemButton, { children: /* @__PURE__ */ jsx(Trans, { message: "Add new localization" }) }), + /* @__PURE__ */ jsx(CreateLocationDialog, {}) + ] }) }); +} +function RowActionsMenuTrigger({ locale }) { + const uploadFile2 = useUploadTranslationFile(); + return /* @__PURE__ */ jsxs(MenuTrigger, { children: [ + /* @__PURE__ */ jsx(IconButton, { disabled: uploadFile2.isPending, children: /* @__PURE__ */ jsx(MoreVertIcon, {}) }), + /* @__PURE__ */ jsxs(Menu, { children: [ + /* @__PURE__ */ jsx( + Item, + { + value: "translate", + elementType: Link, + to: `${locale.id}/translate`, + children: /* @__PURE__ */ jsx(Trans, { message: "Translate" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "rename", + onSelected: () => openDialog(UpdateLocalizationDialog, { localization: locale }), + children: /* @__PURE__ */ jsx(Trans, { message: "Rename" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "download", + onSelected: () => downloadFileFromUrl(`api/v1/localizations/${locale.id}/download`), + children: /* @__PURE__ */ jsx(Trans, { message: "Download" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "upload", + onSelected: async () => { + const files = await openUploadWindow({ + types: [UploadInputType.json] + }); + if (files.length == 1) { + uploadFile2.mutate({ localeId: locale.id, file: files[0] }); + } + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Upload" }) + } + ) + ] }) + ] }); +} +function NewTranslationDialog() { + const { formId, close } = useDialogContext(); + const form = useForm(); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Add translation" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs( + Form$1, + { + form, + id: formId, + onSubmit: (values) => { + close(values); + }, + children: [ + /* @__PURE__ */ jsx( + SectionHelper, + { + className: "mb-30", + title: /* @__PURE__ */ jsx(Trans, { message: "Add a new translation, if it does not exist already." }), + description: /* @__PURE__ */ jsx(Trans, { message: "This should only need to be done for things like custom menu items." }) + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + inputElementType: "textarea", + rows: 2, + autoFocus: true, + name: "key", + label: /* @__PURE__ */ jsx(Trans, { message: "Translation key" }), + className: "mb-30", + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + inputElementType: "textarea", + rows: 2, + name: "value", + label: /* @__PURE__ */ jsx(Trans, { message: "Translation value" }), + required: true + } + ) + ] + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: close, children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx(Button, { variant: "flat", color: "primary", type: "submit", form: formId, children: /* @__PURE__ */ jsx(Trans, { message: "Add" }) }) + ] }) + ] }); +} +function TranslationManagementPage() { + const { localeId } = useParams(); + const { data, isLoading } = useLocaleWithLines(localeId); + const localization = data == null ? void 0 : data.localization; + if (isLoading || !localization) { + return /* @__PURE__ */ jsx(FullPageLoader, {}); + } + return /* @__PURE__ */ jsx(Form, { localization }); +} +function Form({ localization }) { + const [lines, setLines] = useState(localization.lines || {}); + const navigate = useNavigate$1(); + const updateLocalization = useUpdateLocalization(); + const [searchQuery, setSearchQuery] = useState(""); + return /* @__PURE__ */ jsxs( + "form", + { + className: "flex h-full flex-col p-14 md:p-24", + onSubmit: (e) => { + e.preventDefault(); + updateLocalization.mutate( + { id: localization.id, lines }, + { + onSuccess: () => { + navigate("/admin/localizations"); + } + } + ); + }, + children: [ + /* @__PURE__ */ jsx( + Header$3, + { + localization, + setLines, + lines, + searchQuery, + setSearchQuery, + isLoading: updateLocalization.isPending + } + ), + /* @__PURE__ */ jsx(LinesList, { lines, setLines, searchQuery }) + ] + } + ); +} +function Header$3({ + localization, + searchQuery, + setSearchQuery, + isLoading, + lines, + setLines +}) { + const navigate = useNavigate$1(); + const isMobile = useIsMobileMediaQuery(); + const { trans } = useTrans(); + return /* @__PURE__ */ jsxs("div", { className: "flex-shrink-0", children: [ + /* @__PURE__ */ jsxs(Breadcrumb, { size: "lg", className: "mb-16", children: [ + /* @__PURE__ */ jsx( + BreadcrumbItem, + { + onSelected: () => { + navigate("/admin/localizations"); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Localizations" }) + } + ), + /* @__PURE__ */ jsx(BreadcrumbItem, { children: /* @__PURE__ */ jsx( + Trans, + { + message: ":locale translations", + values: { locale: localization.name } + } + ) }) + ] }), + /* @__PURE__ */ jsxs("div", { className: "mb-24 flex items-center gap-32 md:gap-12", children: [ + /* @__PURE__ */ jsx("div", { className: "max-w-440 flex-auto", children: /* @__PURE__ */ jsx( + TextField, + { + value: searchQuery, + onChange: (e) => setSearchQuery(e.target.value), + startAdornment: /* @__PURE__ */ jsx(SearchIcon, {}), + placeholder: trans({ message: "Type to search..." }) + } + ) }), + /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (newTranslation) => { + if (newTranslation) { + const newLines = { ...lines }; + newLines[newTranslation.key] = newTranslation.value; + setLines(newLines); + } + }, + children: [ + !isMobile && /* @__PURE__ */ jsx( + Button, + { + className: "ml-auto", + variant: "outline", + color: "primary", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + children: /* @__PURE__ */ jsx(Trans, { message: "Add new" }) + } + ), + /* @__PURE__ */ jsx(NewTranslationDialog, {}) + ] + } + ), + /* @__PURE__ */ jsx(ActionsMenuTrigger, { locale: localization }), + /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + type: "submit", + disabled: isLoading, + children: isMobile ? /* @__PURE__ */ jsx(Trans, { message: "Save" }) : /* @__PURE__ */ jsx(Trans, { message: "Save translations" }) + } + ) + ] }) + ] }); +} +function LinesList({ searchQuery, lines, setLines }) { + const filteredLines = useMemo(() => { + return Object.entries(lines).filter(([id, translation]) => { + const lowerCaseQuery = searchQuery == null ? void 0 : searchQuery.toLowerCase(); + return !lowerCaseQuery || (id == null ? void 0 : id.toLowerCase().includes(lowerCaseQuery)) || (translation == null ? void 0 : translation.toLowerCase().includes(lowerCaseQuery)); + }); + }, [lines, searchQuery]); + const ref = useRef(null); + const rowVirtualizer = useVirtualizer({ + count: filteredLines.length, + getScrollElement: () => ref.current, + estimateSize: () => 123 + }); + return /* @__PURE__ */ jsx("div", { className: "flex-auto overflow-y-auto", ref, children: /* @__PURE__ */ jsx( + "div", + { + className: "relative w-full", + style: { + height: `${rowVirtualizer.getTotalSize()}px` + }, + children: rowVirtualizer.getVirtualItems().map((virtualItem) => { + const [id, translation] = filteredLines[virtualItem.index]; + return /* @__PURE__ */ jsx( + "div", + { + className: "absolute left-0 top-0 w-full", + style: { + height: `${virtualItem.size}px`, + transform: `translateY(${virtualItem.start}px)` + }, + children: /* @__PURE__ */ jsxs("div", { className: "mb-10 rounded border md:mr-10", children: [ + /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-24 border-b px-10 py-2", children: [ + /* @__PURE__ */ jsx( + "label", + { + className: "flex-auto text-xs font-semibold", + htmlFor: id, + children: id + } + ), + /* @__PURE__ */ jsx( + IconButton, + { + size: "xs", + className: "text-muted", + onClick: () => { + const newLines = { ...lines }; + delete newLines[id]; + setLines(newLines); + }, + children: /* @__PURE__ */ jsx(CloseIcon, {}) + } + ) + ] }), + /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx( + "textarea", + { + id, + name: id, + defaultValue: translation, + className: "block w-full resize-none rounded bg-inherit p-10 text-sm outline-none focus-visible:ring-2", + rows: 2, + onChange: (e) => { + const newLines = { ...lines }; + newLines[id] = e.target.value; + setLines(newLines); + } + } + ) }) + ] }) + }, + id + ); + }) + } + ) }); +} +function ActionsMenuTrigger({ locale }) { + const uploadFile2 = useUploadTranslationFile(); + return /* @__PURE__ */ jsxs(MenuTrigger, { children: [ + /* @__PURE__ */ jsx( + IconButton, + { + variant: "outline", + size: "sm", + color: "primary", + disabled: uploadFile2.isPending, + children: /* @__PURE__ */ jsx(MoreVertIcon, {}) + } + ), + /* @__PURE__ */ jsxs(Menu, { children: [ + /* @__PURE__ */ jsx( + Item, + { + value: "download", + onSelected: () => downloadFileFromUrl(`api/v1/localizations/${locale.id}/download`), + children: /* @__PURE__ */ jsx(Trans, { message: "Download" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "upload", + onSelected: async () => { + const files = await openUploadWindow({ + types: [UploadInputType.json] + }); + if (files.length == 1) { + uploadFile2.mutate({ localeId: locale.id, file: files[0] }); + } + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Upload" }) + } + ) + ] }) + ] }); +} +function AdsPage() { + var _a2; + const query = useAdminSettings(); + return /* @__PURE__ */ jsxs("div", { className: "container mx-auto p-12 md:p-24", children: [ + /* @__PURE__ */ jsx(StaticPageTitle, { children: /* @__PURE__ */ jsx(Trans, { message: "Ads" }) }), + /* @__PURE__ */ jsx("h1", { className: "mb-20 text-2xl font-light md:mb-40 md:text-3xl", children: /* @__PURE__ */ jsx(Trans, { message: "Predefined Ad slots" }) }), + query.isLoading ? /* @__PURE__ */ jsx(ProgressCircle, { isIndeterminate: true }) : /* @__PURE__ */ jsx(AdsForm, { defaultValues: ((_a2 = query.data) == null ? void 0 : _a2.client.ads) || {} }) + ] }); +} +function AdsForm({ defaultValues }) { + const { + admin: { ads } + } = useContext(SiteConfigContext); + const form = useForm({ + defaultValues: { client: { ads: defaultValues } } + }); + const updateSettings = useUpdateAdminSettings(form); + return /* @__PURE__ */ jsxs( + Form$1, + { + form, + onSubmit: (value) => { + updateSettings.mutate(value); + }, + children: [ + ads.map((ad) => { + return /* @__PURE__ */ jsx(AdSection, { adConfig: ad }, ad.slot); + }), + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "client.ads.disable", + className: "mb-30", + description: /* @__PURE__ */ jsx(Trans, { message: "Disable all add related functionality across the site." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Disable ads" }) + } + ), + /* @__PURE__ */ jsx( + Button, + { + type: "submit", + variant: "flat", + color: "primary", + disabled: updateSettings.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ) + ] + } + ); +} +function AdSection({ adConfig }) { + return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-24", children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-30 flex-auto", + name: `client.${adConfig.slot}`, + inputElementType: "textarea", + rows: 8, + label: /* @__PURE__ */ jsx(Trans, { ...adConfig.description }) + } + ), + /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx( + "button", + { + type: "button", + className: "cursor-zoom-in overflow-hidden rounded outline-none transition hover:scale-105 focus-visible:ring max-md:hidden", + children: /* @__PURE__ */ jsx( + "img", + { + src: adConfig.image, + className: "h-[186px] w-auto border", + alt: "Ad slot example" + } + ) + } + ), + /* @__PURE__ */ jsx(ImageZoomDialog, { image: adConfig.image }) + ] }) + ] }); +} +function SectionList() { + const sections = useAppearanceStore((s) => { + var _a2; + return (_a2 = s.config) == null ? void 0 : _a2.sections; + }); + const sortedSection = useMemo(() => { + if (!sections) + return []; + return Object.entries(sections || []).map(([key, value]) => { + return { + ...value, + key + }; + }).sort((a, b) => ((a == null ? void 0 : a.position) || 1) - ((b == null ? void 0 : b.position) || 1)); + }, [sections]); + return /* @__PURE__ */ jsx(Fragment, { children: sortedSection.map((section) => { + return /* @__PURE__ */ jsx( + AppearanceButton, + { + to: section.key, + elementType: NavLink, + children: /* @__PURE__ */ jsx(Trans, { ...section.label }) + }, + section.key + ); + }) }); +} +const RoleIndexPageFilters = [ + { + key: "type", + label: message("Type"), + description: message("Type of the role"), + defaultOperator: FilterOperator.ne, + control: { + type: FilterControlType.Select, + defaultValue: "01", + options: [ + { + key: "01", + label: message("Sitewide"), + value: "sitewide" + }, + { + key: "02", + label: message("Workspace"), + value: "workspace" + } + ] + } + }, + createdAtFilter({ + description: message("Date role was created") + }), + updatedAtFilter({ + description: message("Date role was last updated") + }) +]; +const columnConfig$9 = [ + { + key: "name", + allowsSorting: true, + visibleInMode: "all", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Role" }), + body: (role) => /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Trans, { message: role.name }) }), + /* @__PURE__ */ jsx("div", { className: "text-muted text-xs overflow-x-hidden overflow-ellipsis", children: role.description ? /* @__PURE__ */ jsx(Trans, { message: role.description }) : void 0 }) + ] }) + }, + { + key: "type", + maxWidth: "max-w-100", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Type" }), + body: (role) => /* @__PURE__ */ jsx(Trans, { message: role.type }) + }, + { + key: "updated_at", + maxWidth: "max-w-100", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (role) => /* @__PURE__ */ jsx(FormattedDate, { date: role.updated_at }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + visibleInMode: "all", + align: "end", + width: "w-42 flex-shrink-0", + body: (role) => { + return /* @__PURE__ */ jsx(Link, { to: `${role.id}/edit`, children: /* @__PURE__ */ jsx(IconButton, { size: "md", className: "text-muted", children: /* @__PURE__ */ jsx(EditIcon, {}) }) }); + } + } +]; +function RolesIndexPage() { + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "roles", + title: /* @__PURE__ */ jsx(Trans, { message: "Roles" }), + columns: columnConfig$9, + filters: RoleIndexPageFilters, + actions: /* @__PURE__ */ jsx(Actions$c, {}), + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: teamSvg, + title: /* @__PURE__ */ jsx(Trans, { message: "No roles have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching roles" }) + } + ) + } + ); +} +function Actions$c() { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(DataTableExportCsvButton, { endpoint: "roles/csv/export" }), + /* @__PURE__ */ jsx(DataTableAddItemButton, { elementType: Link, to: "new", children: /* @__PURE__ */ jsx(Trans, { message: "Add new role" }) }) + ] }); +} +const Endpoint$6 = (id) => `roles/${id}`; +function useRole() { + const { roleId } = useParams(); + return useQuery({ + queryKey: [Endpoint$6(roleId)], + queryFn: () => fetchRole(roleId) + }); +} +function fetchRole(roleId) { + return apiClient.get(Endpoint$6(roleId)).then((response) => response.data); +} +const Endpoint$5 = (id) => `roles/${id}`; +function useUpdateRole() { + const { trans } = useTrans(); + const navigate = useNavigate$1(); + return useMutation({ + mutationFn: (payload) => updateRole(payload), + onSuccess: (response) => { + toast(trans(message("Role updated"))); + queryClient.invalidateQueries({ queryKey: [Endpoint$5(response.role.id)] }); + queryClient.invalidateQueries({ queryKey: DatatableDataQueryKey("roles") }); + navigate("/admin/roles"); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function updateRole({ id, ...payload }) { + return apiClient.put(Endpoint$5(id), payload).then((r) => r.data); +} +function CrupdateRolePageSettingsPanel({ + isInternal = false +}) { + const { trans } = useTrans(); + const { workspaces } = useSettings(); + const { watch, setValue } = useFormContext(); + const watchedType = watch("type"); + return /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Name" }), + name: "name", + className: "mb-20", + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Description" }), + name: "description", + inputElementType: "textarea", + placeholder: trans(message("Role description...")), + rows: 4, + className: "mb-20" + } + ), + workspaces.integrated && /* @__PURE__ */ jsxs( + FormSelect, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Type" }), + name: "type", + selectionMode: "single", + className: "mb-20", + description: /* @__PURE__ */ jsx(Trans, { message: "Whether this role will be assigned to users globally on the site or only within workspaces." }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "sitewide", children: /* @__PURE__ */ jsx(Trans, { message: "Sitewide" }) }), + /* @__PURE__ */ jsx(Item, { value: "workspace", children: /* @__PURE__ */ jsx(Trans, { message: "Workspace" }) }) + ] + } + ), + !isInternal && /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "default", + className: "mb-20", + description: /* @__PURE__ */ jsx(Trans, { message: "Assign this role to new users automatically." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Default" }) + } + ), + watchedType === "sitewide" && /* @__PURE__ */ jsx( + FormSwitch, + { + name: "guests", + description: /* @__PURE__ */ jsx(Trans, { message: "Assign this role to guests (not logged in users)." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Guests" }) + } + ) + ] }), + /* @__PURE__ */ jsxs("div", { className: "mb-14 mt-30 flex items-end justify-between gap-12", children: [ + /* @__PURE__ */ jsx("h2", { className: "text-lg leading-tight", children: /* @__PURE__ */ jsx(Trans, { message: "Permissions" }) }), + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + size: "xs", + onClick: () => setValue("permissions", []), + children: /* @__PURE__ */ jsx(Trans, { message: "Remove all" }) + } + ) + ] }), + /* @__PURE__ */ jsx( + FormPermissionSelector, + { + name: "permissions", + valueListKey: watchedType === "sitewide" ? "permissions" : "workspacePermissions" + } + ) + ] }); +} +function SelectUserDialog({ onUserSelected }) { + var _a2; + const { close } = useDialogContext(); + const [searchTerm, setSearchTerm] = useState(""); + const { trans } = useTrans(); + const query = useNormalizedModels("normalized-models/user", { + query: searchTerm, + perPage: 14 + }); + const users = ((_a2 = query.data) == null ? void 0 : _a2.results) || []; + const emptyStateMessage = /* @__PURE__ */ jsx( + IllustratedMessage, + { + className: "pt-20", + size: "sm", + title: /* @__PURE__ */ jsx(Trans, { message: "No matching users" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Try another search query" }), + image: /* @__PURE__ */ jsx(SvgImage, { src: teamSvg }) + } + ); + const selectUser = (user) => { + close(); + onUserSelected(user); + }; + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Select a user" }) }), + /* @__PURE__ */ jsxs(DialogBody, { children: [ + /* @__PURE__ */ jsx( + TextField, + { + autoFocus: true, + className: "mb-20", + startAdornment: /* @__PURE__ */ jsx(SearchIcon, {}), + placeholder: trans(message("Search for user by name or email")), + value: searchTerm, + onChange: (e) => { + setSearchTerm(e.target.value); + } + } + ), + !query.isLoading && !users.length && emptyStateMessage, + /* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-x-10", children: users.map((user) => /* @__PURE__ */ jsx( + UserListItem, + { + user, + onUserSelected: selectUser + }, + user.id + )) }) + ] }) + ] }); +} +function UserListItem({ user, onUserSelected }) { + return /* @__PURE__ */ jsxs( + "div", + { + className: "flex items-center gap-10 rounded p-10 outline-none ring-offset-4 hover:bg-hover focus-visible:ring", + role: "button", + tabIndex: 0, + onClick: () => { + onUserSelected(user); + }, + onKeyDown: (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onUserSelected(user); + } + }, + children: [ + /* @__PURE__ */ jsx(Avatar, { src: user.image }), + /* @__PURE__ */ jsxs("div", { className: "overflow-hidden", children: [ + /* @__PURE__ */ jsx("div", { className: "overflow-hidden text-ellipsis", children: user.name }), + /* @__PURE__ */ jsx("div", { className: "overflow-hidden text-ellipsis text-muted", children: user.description }) + ] }) + ] + }, + user.id + ); +} +function useRemoveUsersFromRole(role) { + return useMutation({ + mutationFn: ({ userIds }) => removeUsersFromRole({ userIds, roleId: role.id }), + onSuccess: (response, payload) => { + toast( + message("Removed [one 1 user|other :count users] from “{role}“", { + values: { count: payload.userIds.length, role: role.name } + }) + ); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function removeUsersFromRole({ + roleId, + userIds +}) { + return apiClient.post(`roles/${roleId}/remove-users`, { userIds }).then((r) => r.data); +} +function useAddUsersToRole(role) { + return useMutation({ + mutationFn: ({ userIds }) => addUsersToRole({ userIds, roleId: role.id }), + onSuccess: (response, payload) => { + toast( + message("Assigned [one 1 user|other :count users] to {role}", { + values: { count: payload.userIds.length, role: role.name } + }) + ); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function addUsersToRole({ + roleId, + userIds +}) { + return apiClient.post(`roles/${roleId}/add-users`, { userIds }).then((r) => r.data); +} +const userColumn = { + key: "name", + allowsSorting: true, + sortingKey: "email", + header: () => /* @__PURE__ */ jsx(Trans, { message: "User" }), + body: (user) => /* @__PURE__ */ jsx( + NameWithAvatar, + { + image: user.avatar, + label: user.display_name, + description: user.email + } + ), + width: "col-w-3" +}; +const desktopColumns = [ + userColumn, + { + key: "first_name", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "First name" }), + body: (user) => user.first_name + }, + { + key: "last_name", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last name" }), + body: (user) => user.last_name + }, + { + key: "created_at", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Assigned at" }), + body: (user) => /* @__PURE__ */ jsx(FormattedDate, { date: user.created_at }) + } +]; +const mobileColumns = [userColumn]; +function EditRolePageUsersPanel({ + role +}) { + const isMobile = useIsMobileMediaQuery(); + if (role.guests || role.type === "workspace") { + return /* @__PURE__ */ jsx("div", { className: "pt-30 pb-10", children: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: teamSvg, + title: /* @__PURE__ */ jsx(Trans, { message: "Users can't be assigned to this role" }) + } + ) }); + } + return /* @__PURE__ */ jsx( + DataTable, + { + endpoint: "users", + columns: isMobile ? mobileColumns : desktopColumns, + queryParams: { roleId: `${role.id}` }, + actions: /* @__PURE__ */ jsx(AssignUserAction, { role }), + selectedActions: /* @__PURE__ */ jsx(RemoveUsersAction, { role }), + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: teamSvg, + title: /* @__PURE__ */ jsx(Trans, { message: "No users have been assigned to this role yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching users" }) + } + ) + } + ); +} +function AssignUserAction({ role }) { + const addUsers = useAddUsersToRole(role); + return /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(Button, { variant: "flat", color: "primary", disabled: addUsers.isPending, children: /* @__PURE__ */ jsx(Trans, { message: "Assign user" }) }), + /* @__PURE__ */ jsx( + SelectUserDialog, + { + onUserSelected: (user) => { + addUsers.mutate( + { userIds: [user.id] }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("users", { + roleId: `${role.id}` + }) + }); + } + } + ); + } + } + ) + ] }); +} +function RemoveUsersAction({ role }) { + const removeUsers = useRemoveUsersFromRole(role); + const { selectedRows } = useDataTable(); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (isConfirmed) => { + if (isConfirmed) { + removeUsers.mutate( + { userIds: selectedRows }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("users", { + roleId: `${role.id}` + }) + }); + } + } + ); + } + }, + children: [ + /* @__PURE__ */ jsx(Button, { variant: "flat", color: "danger", disabled: removeUsers.isPending, children: /* @__PURE__ */ jsx(Trans, { message: "Remove users" }) }), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + title: /* @__PURE__ */ jsx( + Trans, + { + message: "Remove [one 1 user|other :count users] from “:name“ role?", + values: { count: selectedRows.length, name: role.name } + } + ), + body: /* @__PURE__ */ jsx(Trans, { message: "This will permanently remove the users." }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Remove" }), + isDanger: true + } + ) + ] + } + ); +} +function EditRolePage() { + const query = useRole(); + if (query.status !== "success") { + return /* @__PURE__ */ jsx(FullPageLoader, {}); + } + return /* @__PURE__ */ jsx(PageContent$5, { role: query.data.role }); +} +function PageContent$5({ role }) { + const form = useForm({ defaultValues: role }); + const updateRole2 = useUpdateRole(); + return /* @__PURE__ */ jsx( + CrupdateResourceLayout, + { + form, + onSubmit: (values) => { + updateRole2.mutate(values); + }, + title: /* @__PURE__ */ jsx(Trans, { message: "Edit “:name“ role", values: { name: role.name } }), + isLoading: updateRole2.isPending, + children: /* @__PURE__ */ jsxs(Tabs, { isLazy: true, children: [ + /* @__PURE__ */ jsxs(TabList, { children: [ + /* @__PURE__ */ jsx(Tab, { children: /* @__PURE__ */ jsx(Trans, { message: "Settings" }) }), + /* @__PURE__ */ jsx(Tab, { children: /* @__PURE__ */ jsx(Trans, { message: "Users" }) }) + ] }), + /* @__PURE__ */ jsxs(TabPanels, { className: "pt-20", children: [ + /* @__PURE__ */ jsx(TabPanel, { children: /* @__PURE__ */ jsx(CrupdateRolePageSettingsPanel, { isInternal: role.internal }) }), + /* @__PURE__ */ jsx(TabPanel, { children: /* @__PURE__ */ jsx(EditRolePageUsersPanel, { role }) }) + ] }) + ] }) + } + ); +} +const Endpoint$4 = "roles"; +function useCreateRole(form) { + const { trans } = useTrans(); + return useMutation({ + mutationFn: (payload) => createRole(payload), + onSuccess: () => { + toast(trans(message("Created new role"))); + queryClient.invalidateQueries({ queryKey: DatatableDataQueryKey("roles") }); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function createRole({ id, ...payload }) { + return apiClient.post(Endpoint$4, payload).then((r) => r.data); +} +function CreateRolePage() { + const form = useForm({ defaultValues: { type: "sitewide" } }); + const createRole2 = useCreateRole(form); + const navigate = useNavigate$1(); + return /* @__PURE__ */ jsx( + CrupdateResourceLayout, + { + form, + onSubmit: (values) => { + createRole2.mutate(values, { + onSuccess: (response) => { + navigate(`/admin/roles/${response.role.id}/edit`); + } + }); + }, + title: /* @__PURE__ */ jsx(Trans, { message: "Add new role" }), + isLoading: createRole2.isPending, + children: /* @__PURE__ */ jsx(CrupdateRolePageSettingsPanel, {}) + } + ); +} +const TagIndexPageFilters = (types) => { + return [ + { + key: "type", + label: message("Type"), + description: message("Type of the tag"), + defaultOperator: FilterOperator.ne, + control: { + type: FilterControlType.Select, + defaultValue: types[0].name, + options: types.map((type) => ({ + key: type.name, + label: message(type.name), + value: type.name + })) + } + }, + createdAtFilter({ + description: message("Date tag was created") + }), + updatedAtFilter({ + description: message("Date tag was last updated") + }) + ]; +}; +const softwareEngineerSvg = "/assets/software-engineer-ba026106.svg"; +function CrupdateTagForm({ + form, + onSubmit, + formId +}) { + const { + tags: { types } + } = useContext(SiteConfigContext); + const watchedType = form.watch("type"); + const isSystem = !!types.find((t) => t.name === watchedType && t.system); + return /* @__PURE__ */ jsxs(Form$1, { id: formId, form, onSubmit, children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Name" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Unique tag identifier." }), + className: "mb-20", + required: true, + autoFocus: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "display_name", + label: /* @__PURE__ */ jsx(Trans, { message: "Display name" }), + description: /* @__PURE__ */ jsx(Trans, { message: "User friendly tag name." }), + className: "mb-20" + } + ), + /* @__PURE__ */ jsx( + FormSelect, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Type" }), + name: "type", + selectionMode: "single", + disabled: isSystem, + children: types.filter((t) => !t.system).map((type) => /* @__PURE__ */ jsx(Item, { value: type.name, children: /* @__PURE__ */ jsx(Trans, { message: type.name }) }, type.name)) + } + ) + ] }); +} +function useCreateNewTag(form) { + const { trans } = useTrans(); + return useMutation({ + mutationFn: (props) => createNewTag$1(props), + onSuccess: () => { + toast(trans(message("Tag created"))); + queryClient.invalidateQueries({ queryKey: DatatableDataQueryKey("tags") }); + }, + onError: (err) => onFormQueryError(err, form) + }); +} +function createNewTag$1(payload) { + payload.name = slugifyString(payload.name); + return apiClient.post("tags", payload).then((r) => r.data); +} +function CreateTagDialog() { + const { close, formId } = useDialogContext(); + const { + tags: { types } + } = useContext(SiteConfigContext); + const form = useForm({ + defaultValues: { + type: types[0].name + } + }); + const createNewTag2 = useCreateNewTag(form); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Add new tag" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsx( + CrupdateTagForm, + { + formId, + form, + onSubmit: (values) => { + createNewTag2.mutate(values, { + onSuccess: () => { + close(); + } + }); + } + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx( + Button, + { + onClick: () => { + close(); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) + } + ), + /* @__PURE__ */ jsx( + Button, + { + form: formId, + disabled: createNewTag2.isPending, + variant: "flat", + color: "primary", + type: "submit", + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ) + ] }) + ] }); +} +function useUpdateTag(form) { + const { trans } = useTrans(); + return useMutation({ + mutationFn: (props) => updateTag$1(props), + onSuccess: () => { + toast(trans(message("Tag updated"))); + queryClient.invalidateQueries({ queryKey: DatatableDataQueryKey("tags") }); + }, + onError: (err) => onFormQueryError(err, form) + }); +} +function updateTag$1({ id, ...payload }) { + if (payload.name) { + payload.name = slugifyString(payload.name); + } + return apiClient.put(`tags/${id}`, payload).then((r) => r.data); +} +function UpdateTagDialog({ tag }) { + const { close, formId } = useDialogContext(); + const form = useForm({ + defaultValues: { + id: tag.id, + name: tag.name, + display_name: tag.display_name, + type: tag.type + } + }); + const updateTag2 = useUpdateTag(form); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Update “:name“ tag", values: { name: tag.name } }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsx( + CrupdateTagForm, + { + formId, + form, + onSubmit: (values) => { + updateTag2.mutate(values, { + onSuccess: () => { + close(); + } + }); + } + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx( + Button, + { + onClick: () => { + close(); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) + } + ), + /* @__PURE__ */ jsx( + Button, + { + form: formId, + disabled: updateTag2.isPending, + variant: "flat", + color: "primary", + type: "submit", + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ) + ] }) + ] }); +} +const columnConfig$8 = [ + { + key: "name", + allowsSorting: true, + visibleInMode: "all", + width: "flex-3 min-w-200", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Name" }), + body: (tag) => tag.name + }, + { + key: "type", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Type" }), + body: (tag) => tag.type + }, + { + key: "display_name", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Display name" }), + body: (tag) => tag.display_name + }, + { + key: "updated_at", + allowsSorting: true, + width: "w-100", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (tag) => /* @__PURE__ */ jsx(FormattedDate, { date: tag.updated_at }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + align: "end", + width: "w-42 flex-shrink-0", + visibleInMode: "all", + body: (tag) => { + return /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(IconButton, { size: "md", className: "text-muted", children: /* @__PURE__ */ jsx(EditIcon, {}) }), + /* @__PURE__ */ jsx(UpdateTagDialog, { tag }) + ] }); + } + } +]; +function TagIndexPage() { + const { tags } = useContext(SiteConfigContext); + const filters = useMemo(() => { + return TagIndexPageFilters(tags.types); + }, [tags.types]); + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "tags", + title: /* @__PURE__ */ jsx(Trans, { message: "Tags" }), + columns: columnConfig$8, + filters, + actions: /* @__PURE__ */ jsx(Actions$b, {}), + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: softwareEngineerSvg, + title: /* @__PURE__ */ jsx(Trans, { message: "No tags have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching tags" }) + } + ) + } + ); +} +function Actions$b() { + return /* @__PURE__ */ jsx(Fragment$1, { children: /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(DataTableAddItemButton, { children: /* @__PURE__ */ jsx(Trans, { message: "Add new tag" }) }), + /* @__PURE__ */ jsx(CreateTagDialog, {}) + ] }) }); +} +const FormattedBytes = memo(({ bytes }) => { + return /* @__PURE__ */ jsx(Fragment, { children: prettyBytes(bytes) }); +}); +const VisibilityIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M12 6c3.79 0 7.17 2.13 8.82 5.5C19.17 14.87 15.79 17 12 17s-7.17-2.13-8.82-5.5C4.83 8.13 8.21 6 12 6m0-2C7 4 2.73 7.11 1 11.5 2.73 15.89 7 19 12 19s9.27-3.11 11-7.5C21.27 7.11 17 4 12 4zm0 5c1.38 0 2.5 1.12 2.5 2.5S13.38 14 12 14s-2.5-1.12-2.5-2.5S10.62 9 12 9m0-2c-2.48 0-4.5 2.02-4.5 4.5S9.52 16 12 16s4.5-2.02 4.5-4.5S14.48 7 12 7z" }), + "VisibilityOutlined" +); +const uploadSvg = "/assets/upload-cabfc914.svg"; +const FileEntryUrlsContext = React.createContext(null); +function useFileEntryUrls(entry, options) { + const { base_url } = useSettings(); + const urlSearchParams = useContext(FileEntryUrlsContext); + return useMemo(() => { + if (!entry) { + return {}; + } + let previewUrl; + if (entry.url) { + previewUrl = isAbsoluteUrl(entry.url) ? entry.url : `${base_url}/${entry.url}`; + } + const urls = { + previewUrl, + downloadUrl: `${base_url}/api/v1/file-entries/download/${(options == null ? void 0 : options.downloadHashes) || entry.hash}` + }; + if (urlSearchParams) { + if (urls.previewUrl) { + urls.previewUrl = addParams( + urls.previewUrl, + { ...urlSearchParams, thumbnail: (options == null ? void 0 : options.thumbnail) ? "true" : "" }, + base_url + ); + } + urls.downloadUrl = addParams(urls.downloadUrl, urlSearchParams, base_url); + } + return urls; + }, [ + base_url, + entry, + options == null ? void 0 : options.downloadHashes, + options == null ? void 0 : options.thumbnail, + urlSearchParams + ]); +} +function addParams(urlString, params, baseUrl) { + const url = new URL(urlString, baseUrl); + Object.entries(params).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + return url.toString(); +} +const FilePreviewContext = React.createContext( + null +); +function DefaultFilePreview({ message: message2, className, allowDownload }) { + const { entries, activeIndex } = useContext(FilePreviewContext); + const activeEntry = entries[activeIndex]; + const content = message2 || /* @__PURE__ */ jsx(Trans, { message: "No file preview available" }); + const { downloadUrl } = useFileEntryUrls(activeEntry); + return /* @__PURE__ */ jsxs( + "div", + { + className: clsx( + className, + "shadow bg-paper max-w-400 w-[calc(100%-40px)] text-center p-40 rounded" + ), + children: [ + /* @__PURE__ */ jsx("div", { className: "text-lg", children: content }), + allowDownload && /* @__PURE__ */ jsx("div", { className: "block mt-20 text-center", children: /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + onClick: () => { + if (downloadUrl) { + downloadFileFromUrl(downloadUrl); + } + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Download" }) + } + ) }) + ] + } + ); +} +function ImageFilePreview(props) { + const { entry, className } = props; + const { trans } = useTrans(); + const { previewUrl } = useFileEntryUrls(entry); + if (!previewUrl) { + return /* @__PURE__ */ jsx(DefaultFilePreview, { ...props }); + } + return /* @__PURE__ */ jsx( + "img", + { + className: clsx(className, "shadow"), + src: previewUrl, + alt: trans({ + message: "Preview for :name", + values: { name: entry.name } + }) + } + ); +} +const FIVE_MB = 5242880; +function TextFilePreview(props) { + const { entry, className } = props; + const { trans } = useTrans(); + const [tooLarge, setTooLarge] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isFailed, setIsFailed] = useState(false); + const [contents, setContents] = useState(null); + const { previewUrl } = useFileEntryUrls(entry); + useEffect(() => { + if (!entry) + return; + if (!previewUrl) { + setIsFailed(true); + } else if (entry.file_size >= FIVE_MB) { + setTooLarge(true); + setIsLoading(false); + } else { + getFileContents(previewUrl).then((response) => { + setContents(response.data); + }).catch(() => { + setIsFailed(true); + }).finally(() => { + setIsLoading(false); + }); + } + }, [entry, previewUrl]); + if (isLoading) { + return /* @__PURE__ */ jsx( + ProgressCircle, + { + isIndeterminate: true, + "aria-label": trans({ message: "Loading file contents" }) + } + ); + } + if (tooLarge) { + return /* @__PURE__ */ jsx( + DefaultFilePreview, + { + ...props, + message: /* @__PURE__ */ jsx(Trans, { message: "This file is too large to preview." }) + } + ); + } + if (isFailed) { + return /* @__PURE__ */ jsx( + DefaultFilePreview, + { + ...props, + message: /* @__PURE__ */ jsx(Trans, { message: "There was an issue previewing this file" }) + } + ); + } + return /* @__PURE__ */ jsx( + "pre", + { + className: clsx( + "rounded bg-paper p-20 text-sm whitespace-pre-wrap break-words h-full overflow-y-auto w-full", + className + ), + children: /* @__PURE__ */ jsx("div", { className: "container mx-auto", children: `${contents}` }) + } + ); +} +function getFileContents(src) { + return apiClient.get(src, { + responseType: "text", + // required for s3 presigned url to work + withCredentials: false, + headers: { + Accept: "text/plain" + } + }); +} +function VideoFilePreview(props) { + const { entry, className } = props; + const { previewUrl } = useFileEntryUrls(entry); + const ref = useRef(null); + const [mediaInvalid, setMediaInvalid] = useState(false); + useEffect(() => { + var _a2; + setMediaInvalid(!((_a2 = ref.current) == null ? void 0 : _a2.canPlayType(entry.mime))); + }, [entry]); + if (mediaInvalid || !previewUrl) { + return /* @__PURE__ */ jsx(DefaultFilePreview, { ...props }); + } + return /* @__PURE__ */ jsx( + "video", + { + className, + ref, + controls: true, + controlsList: "nodownload noremoteplayback", + playsInline: true, + autoPlay: true, + children: /* @__PURE__ */ jsx( + "source", + { + src: previewUrl, + type: entry.mime, + onError: () => { + setMediaInvalid(true); + } + } + ) + } + ); +} +function AudioFilePreview(props) { + const { entry, className } = props; + const { previewUrl } = useFileEntryUrls(entry); + const ref = useRef(null); + const [mediaInvalid, setMediaInvalid] = useState(false); + useEffect(() => { + var _a2; + setMediaInvalid(!((_a2 = ref.current) == null ? void 0 : _a2.canPlayType(entry.mime))); + }, [entry]); + if (mediaInvalid || !previewUrl) { + return /* @__PURE__ */ jsx(DefaultFilePreview, { ...props }); + } + return /* @__PURE__ */ jsx( + "audio", + { + className, + ref, + controls: true, + controlsList: "nodownload noremoteplayback", + autoPlay: true, + children: /* @__PURE__ */ jsx( + "source", + { + src: previewUrl, + type: entry.mime, + onError: () => { + setMediaInvalid(true); + } + } + ) + } + ); +} +function PdfFilePreview(props) { + const { entry, className } = props; + const { trans } = useTrans(); + const { previewUrl } = useFileEntryUrls(entry); + if (!previewUrl) { + return /* @__PURE__ */ jsx(DefaultFilePreview, { ...props }); + } + return /* @__PURE__ */ jsx( + "iframe", + { + title: trans({ + message: "Preview for :name", + values: { name: entry.name } + }), + className: clsx(className, "w-full h-full"), + src: `${previewUrl}#toolbar=0` + } + ); +} +function WordDocumentFilePreview(props) { + const { entry, className } = props; + const { trans } = useTrans(); + const ref = useRef(null); + const [showDefault, setShowDefault] = useState(false); + const timeoutId = useRef(); + const [isLoading, setIsLoading] = useState(false); + const { previewUrl } = useFileEntryUrls(entry); + useEffect(() => { + if (!previewUrl) { + setShowDefault(true); + } else if (entry.file_size && entry.file_size > 25e6) { + setShowDefault(true); + } else if (ref.current) { + ref.current.onload = () => { + clearTimeout(timeoutId.current); + setIsLoading(false); + }; + buildPreviewUrl(previewUrl, entry).then((url) => { + if (ref.current) { + ref.current.src = url; + } + }); + timeoutId.current = setTimeout(() => { + setShowDefault(true); + }, 5e3); + } + }, [entry, previewUrl]); + if (showDefault) { + return /* @__PURE__ */ jsx(DefaultFilePreview, { ...props }); + } + return /* @__PURE__ */ jsxs("div", { className: clsx(className, "w-full h-full"), children: [ + isLoading && /* @__PURE__ */ jsx(ProgressCircle, {}), + /* @__PURE__ */ jsx( + "iframe", + { + ref, + title: trans({ + message: "Preview for :name", + values: { name: entry.name } + }), + className: clsx("w-full h-full", isLoading && "hidden") + } + ) + ] }); +} +async function buildPreviewUrl(urlString, entry) { + const url = new URL(urlString); + if (!url.searchParams.has("shareable_link")) { + const { data } = await apiClient.post( + `file-entries/${entry.id}/add-preview-token` + ); + url.searchParams.append("preview_token", data.preview_token); + } + return buildOfficeLivePreviewUrl(url); +} +function buildOfficeLivePreviewUrl(url) { + return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent( + url.toString() + )}`; +} +const AvailablePreviews = { + text: TextFilePreview, + video: VideoFilePreview, + audio: AudioFilePreview, + image: ImageFilePreview, + pdf: PdfFilePreview, + spreadsheet: WordDocumentFilePreview, + powerPoint: WordDocumentFilePreview, + word: WordDocumentFilePreview, + "text/rtf": DefaultFilePreview +}; +function getPreviewForEntry(entry) { + const mime = entry == null ? void 0 : entry.mime; + const type = entry == null ? void 0 : entry.type; + return AvailablePreviews[mime] || AvailablePreviews[type] || DefaultFilePreview; +} +const DefaultFileIcon = createSvgIcon( + /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsx("path", { d: "M 23.65625 4 C 22.320313 4 21.066406 4.519531 20.121094 5.464844 L 11.464844 14.121094 C 10.519531 15.066406 10 16.320313 10 17.65625 L 10 57 C 10 58.652344 11.347656 60 13 60 L 53 60 C 54.652344 60 56 58.652344 56 57 L 56 7 C 56 5.347656 54.652344 4 53 4 Z M 24 6 L 53 6 C 53.550781 6 54 6.449219 54 7 L 54 57 C 54 57.550781 53.550781 58 53 58 L 13 58 C 12.449219 58 12 57.550781 12 57 L 12 18 L 21 18 C 22.652344 18 24 16.652344 24 15 Z M 22 6.5 L 22 15 C 22 15.550781 21.550781 16 21 16 L 12.5 16 C 12.605469 15.835938 12.734375 15.679688 12.878906 15.535156 L 21.535156 6.878906 C 21.679688 6.738281 21.835938 6.613281 22 6.5 Z M 21 22 C 20.449219 22 20 22.449219 20 23 C 20 23.550781 20.449219 24 21 24 L 37 24 C 37.550781 24 38 23.550781 38 23 C 38 22.449219 37.550781 22 37 22 Z M 41 22 C 40.449219 22 40 22.449219 40 23 C 40 23.550781 40.449219 24 41 24 L 45 24 C 45.550781 24 46 23.550781 46 23 C 46 22.449219 45.550781 22 45 22 Z M 21 26 C 20.449219 26 20 26.449219 20 27 C 20 27.550781 20.449219 28 21 28 L 41 28 C 41.550781 28 42 27.550781 42 27 C 42 26.449219 41.550781 26 41 26 Z M 21 32 C 20.449219 32 20 32.449219 20 33 C 20 33.550781 20.449219 34 21 34 L 43 34 C 43.550781 34 44 33.550781 44 33 C 44 32.449219 43.550781 32 43 32 Z M 21 36 C 20.449219 36 20 36.449219 20 37 C 20 37.550781 20.449219 38 21 38 L 33 38 C 33.550781 38 34 37.550781 34 37 C 34 36.449219 33.550781 36 33 36 Z M 15 50 C 14.449219 50 14 50.449219 14 51 L 14 53 C 14 53.550781 14.449219 54 15 54 C 15.550781 54 16 53.550781 16 53 L 16 51 C 16 50.449219 15.550781 50 15 50 Z M 20 50 C 19.449219 50 19 50.449219 19 51 L 19 53 C 19 53.550781 19.449219 54 20 54 C 20.550781 54 21 53.550781 21 53 L 21 51 C 21 50.449219 20.550781 50 20 50 Z M 25 50 C 24.449219 50 24 50.449219 24 51 L 24 53 C 24 53.550781 24.449219 54 25 54 C 25.550781 54 26 53.550781 26 53 L 26 51 C 26 50.449219 25.550781 50 25 50 Z M 30 50 C 29.449219 50 29 50.449219 29 51 L 29 53 C 29 53.550781 29.449219 54 30 54 C 30.550781 54 31 53.550781 31 53 L 31 51 C 31 50.449219 30.550781 50 30 50 Z M 35 50 C 34.449219 50 34 50.449219 34 51 L 34 53 C 34 53.550781 34.449219 54 35 54 C 35.550781 54 36 53.550781 36 53 L 36 51 C 36 50.449219 35.550781 50 35 50 Z M 40 50 C 39.449219 50 39 50.449219 39 51 L 39 53 C 39 53.550781 39.449219 54 40 54 C 40.550781 54 41 53.550781 41 53 L 41 51 C 41 50.449219 40.550781 50 40 50 Z M 45 50 C 44.449219 50 44 50.449219 44 51 L 44 53 C 44 53.550781 44.449219 54 45 54 C 45.550781 54 46 53.550781 46 53 L 46 51 C 46 50.449219 45.550781 50 45 50 Z M 50 50 C 49.449219 50 49 50.449219 49 51 L 49 53 C 49 53.550781 49.449219 54 50 54 C 50.550781 54 51 53.550781 51 53 L 51 51 C 51 50.449219 50.550781 50 50 50 Z " }) }) +); +const AudioFileIcon = createSvgIcon( + /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsx("path", { d: "M 21.65625 4 C 20.320313 4 19.0625 4.519531 18.121094 5.464844 L 9.464844 14.121094 C 8.519531 15.066406 8 16.320313 8 17.65625 L 8 57 C 8 58.652344 9.347656 60 11 60 L 51 60 C 52.652344 60 54 58.652344 54 57 L 54 7 C 54 5.347656 52.652344 4 51 4 Z M 22 6 L 51 6 C 51.550781 6 52 6.449219 52 7 L 52 57 C 52 57.550781 51.550781 58 51 58 L 11 58 C 10.449219 58 10 57.550781 10 57 L 10 18 L 19 18 C 20.652344 18 22 16.652344 22 15 Z M 20 6.5 L 20 15 C 20 15.550781 19.550781 16 19 16 L 10.5 16 C 10.609375 15.835938 10.734375 15.679688 10.878906 15.535156 L 19.535156 6.878906 C 19.679688 6.734375 19.835938 6.609375 20 6.5 Z M 42.78125 18.023438 L 24.78125 22.023438 C 24.328125 22.125 24 22.53125 24 23 L 24 37 C 20.691406 37 18 39.242188 18 42 C 18 44.757813 20.691406 47 24 47 C 27.308594 47 30 44.757813 30 42 L 30 29.910156 L 38 28.136719 L 38 33 C 34.691406 33 32 35.242188 32 38 C 32 40.757813 34.691406 43 38 43 C 41.308594 43 44 40.757813 44 38 L 44 19 C 44 18.695313 43.863281 18.410156 43.625 18.21875 C 43.390625 18.03125 43.082031 17.960938 42.78125 18.023438 Z M 42 20.246094 L 42 38 C 42 39.652344 40.207031 41 38 41 C 35.792969 41 34 39.652344 34 38 C 34 36.347656 35.792969 35 38 35 C 38.28125 35 38.5625 35.023438 38.839844 35.066406 C 39.128906 35.117188 39.421875 35.03125 39.648438 34.84375 C 39.871094 34.652344 40 34.375 40 34.078125 L 40 26.890625 C 40 26.585938 39.863281 26.300781 39.625 26.109375 C 39.390625 25.921875 39.078125 25.847656 38.78125 25.910156 L 28.78125 28.136719 C 28.328125 28.238281 28 28.644531 28 29.109375 L 28 42 C 28 43.652344 26.207031 45 24 45 C 21.792969 45 20 43.652344 20 42 C 20 40.347656 21.792969 39 24 39 C 24.28125 39 24.5625 39.023438 24.839844 39.066406 C 25.128906 39.117188 25.425781 39.03125 25.648438 38.84375 C 25.871094 38.652344 26 38.375 26 38.078125 L 26 23.800781 Z M 13 52 C 12.449219 52 12 52.445313 12 53 L 12 55 C 12 55.554688 12.449219 56 13 56 C 13.550781 56 14 55.554688 14 55 L 14 53 C 14 52.445313 13.550781 52 13 52 Z M 18 52 C 17.449219 52 17 52.445313 17 53 L 17 55 C 17 55.554688 17.449219 56 18 56 C 18.550781 56 19 55.554688 19 55 L 19 53 C 19 52.445313 18.550781 52 18 52 Z M 23 52 C 22.449219 52 22 52.445313 22 53 L 22 55 C 22 55.554688 22.449219 56 23 56 C 23.550781 56 24 55.554688 24 55 L 24 53 C 24 52.445313 23.550781 52 23 52 Z M 28 52 C 27.449219 52 27 52.445313 27 53 L 27 55 C 27 55.554688 27.449219 56 28 56 C 28.550781 56 29 55.554688 29 55 L 29 53 C 29 52.445313 28.550781 52 28 52 Z M 33 52 C 32.449219 52 32 52.445313 32 53 L 32 55 C 32 55.554688 32.449219 56 33 56 C 33.550781 56 34 55.554688 34 55 L 34 53 C 34 52.445313 33.550781 52 33 52 Z M 38 52 C 37.449219 52 37 52.445313 37 53 L 37 55 C 37 55.554688 37.449219 56 38 56 C 38.550781 56 39 55.554688 39 55 L 39 53 C 39 52.445313 38.550781 52 38 52 Z M 43 52 C 42.449219 52 42 52.445313 42 53 L 42 55 C 42 55.554688 42.449219 56 43 56 C 43.550781 56 44 55.554688 44 55 L 44 53 C 44 52.445313 43.550781 52 43 52 Z M 48 52 C 47.449219 52 47 52.445313 47 53 L 47 55 C 47 55.554688 47.449219 56 48 56 C 48.550781 56 49 55.554688 49 55 L 49 53 C 49 52.445313 48.550781 52 48 52 Z " }) }) +); +const VideoFileIcon = createSvgIcon( + /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsx("path", { d: "M 23.65625 4 C 22.320313 4 21.0625 4.519531 20.121094 5.464844 L 11.464844 14.121094 C 10.519531 15.066406 10 16.320313 10 17.65625 L 10 57 C 10 58.652344 11.347656 60 13 60 L 53 60 C 54.652344 60 56 58.652344 56 57 L 56 7 C 56 5.347656 54.652344 4 53 4 Z M 24 6 L 53 6 C 53.550781 6 54 6.449219 54 7 L 54 57 C 54 57.550781 53.550781 58 53 58 L 13 58 C 12.449219 58 12 57.550781 12 57 L 12 18 L 21 18 C 22.652344 18 24 16.652344 24 15 Z M 22 6.5 L 22 15 C 22 15.550781 21.550781 16 21 16 L 12.5 16 C 12.613281 15.835938 12.738281 15.675781 12.878906 15.535156 L 21.535156 6.878906 C 21.679688 6.734375 21.835938 6.609375 22 6.5 Z M 28.023438 21.816406 C 27.671875 21.808594 27.316406 21.890625 26.996094 22.0625 C 26.355469 22.417969 25.964844 23.085938 25.964844 23.816406 L 25.964844 42.183594 C 25.964844 42.910156 26.355469 43.582031 26.996094 43.933594 C 27.296875 44.097656 27.632813 44.183594 27.964844 44.183594 C 28.335938 44.183594 28.707031 44.078125 29.03125 43.871094 L 43.53125 34.6875 C 44.113281 34.320313 44.464844 33.6875 44.464844 33 C 44.464844 32.308594 44.113281 31.679688 43.53125 31.3125 L 29.03125 22.125 C 28.722656 21.933594 28.375 21.828125 28.023438 21.816406 Z M 27.964844 23.816406 L 42.464844 33 L 27.964844 42.1875 Z M 15 52 C 14.449219 52 14 52.449219 14 53 L 14 55 C 14 55.550781 14.449219 56 15 56 C 15.550781 56 16 55.550781 16 55 L 16 53 C 16 52.449219 15.550781 52 15 52 Z M 20 52 C 19.449219 52 19 52.449219 19 53 L 19 55 C 19 55.550781 19.449219 56 20 56 C 20.550781 56 21 55.550781 21 55 L 21 53 C 21 52.449219 20.550781 52 20 52 Z M 25 52 C 24.449219 52 24 52.449219 24 53 L 24 55 C 24 55.550781 24.449219 56 25 56 C 25.550781 56 26 55.550781 26 55 L 26 53 C 26 52.449219 25.550781 52 25 52 Z M 30 52 C 29.449219 52 29 52.449219 29 53 L 29 55 C 29 55.550781 29.449219 56 30 56 C 30.550781 56 31 55.550781 31 55 L 31 53 C 31 52.449219 30.550781 52 30 52 Z M 35 52 C 34.449219 52 34 52.449219 34 53 L 34 55 C 34 55.550781 34.449219 56 35 56 C 35.550781 56 36 55.550781 36 55 L 36 53 C 36 52.449219 35.550781 52 35 52 Z M 40 52 C 39.449219 52 39 52.449219 39 53 L 39 55 C 39 55.550781 39.449219 56 40 56 C 40.550781 56 41 55.550781 41 55 L 41 53 C 41 52.449219 40.550781 52 40 52 Z M 45 52 C 44.449219 52 44 52.449219 44 53 L 44 55 C 44 55.550781 44.449219 56 45 56 C 45.550781 56 46 55.550781 46 55 L 46 53 C 46 52.449219 45.550781 52 45 52 Z M 50 52 C 49.449219 52 49 52.449219 49 53 L 49 55 C 49 55.550781 49.449219 56 50 56 C 50.550781 56 51 55.550781 51 55 L 51 53 C 51 52.449219 50.550781 52 50 52 Z " }) }) +); +const TextFileIcon = createSvgIcon( + /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsx("path", { d: "M 17.660156 4 C 16.320313 4 15.058594 4.519531 14.121094 5.460938 L 5.460938 14.121094 C 4.519531 15.070313 4 16.320313 4 17.660156 L 4 57 C 4 58.648438 5.351563 60 7 60 L 47 60 C 48.648438 60 50 58.648438 50 57 L 50 46 L 58 46 C 59.101563 46 60 45.101563 60 44 L 60 24 C 60 22.898438 59.101563 22 58 22 L 50 22 L 50 7 C 50 5.351563 48.648438 4 47 4 Z M 18 6 L 47 6 C 47.550781 6 48 6.449219 48 7 L 48 22 L 16 22 C 14.898438 22 14 22.898438 14 24 L 14 44 C 14 45.101563 14.898438 46 16 46 L 48 46 L 48 57 C 48 57.550781 47.550781 58 47 58 L 7 58 C 6.449219 58 6 57.550781 6 57 L 6 18 L 15 18 C 16.652344 18 18 16.652344 18 15 Z M 16 6.5 L 16 15 C 16 15.550781 15.550781 16 15 16 L 6.5 16 C 6.613281 15.835938 6.738281 15.679688 6.882813 15.539063 L 15.539063 6.882813 C 15.679688 6.738281 15.835938 6.609375 16 6.5 Z M 16 24 L 58 24 L 58 44 L 16 44 Z M 24 28 C 23.449219 28 23 28.445313 23 29 C 23 29.554688 23.449219 30 24 30 L 26 30 L 26 39 C 26 39.554688 26.449219 40 27 40 C 27.550781 40 28 39.554688 28 39 L 28 30 L 30 30 C 30.550781 30 31 29.554688 31 29 C 31 28.445313 30.550781 28 30 28 Z M 44 28 C 43.449219 28 43 28.445313 43 29 C 43 29.554688 43.449219 30 44 30 L 46 30 L 46 39 C 46 39.554688 46.449219 40 47 40 C 47.550781 40 48 39.554688 48 39 L 48 30 L 50 30 C 50.550781 30 51 29.554688 51 29 C 51 28.445313 50.550781 28 50 28 Z M 33.859375 28.011719 C 33.730469 28.027344 33.601563 28.070313 33.484375 28.140625 C 33.011719 28.425781 32.859375 29.039063 33.140625 29.515625 L 35.832031 34 L 33.140625 38.484375 C 32.859375 38.957031 33.011719 39.574219 33.484375 39.859375 C 33.644531 39.953125 33.824219 40 34 40 C 34.339844 40 34.671875 39.828125 34.859375 39.515625 L 37 35.941406 L 39.140625 39.515625 C 39.328125 39.828125 39.660156 40 40 40 C 40.175781 40 40.355469 39.953125 40.515625 39.859375 C 40.988281 39.574219 41.140625 38.957031 40.859375 38.484375 L 38.167969 34 L 40.859375 29.515625 C 41.140625 29.042969 40.988281 28.425781 40.515625 28.140625 C 40.042969 27.859375 39.425781 28.011719 39.140625 28.484375 L 37 32.058594 L 34.859375 28.484375 C 34.644531 28.128906 34.246094 27.957031 33.859375 28.011719 Z M 9 52 C 8.449219 52 8 52.445313 8 53 L 8 55 C 8 55.554688 8.449219 56 9 56 C 9.550781 56 10 55.554688 10 55 L 10 53 C 10 52.445313 9.550781 52 9 52 Z M 14 52 C 13.449219 52 13 52.445313 13 53 L 13 55 C 13 55.554688 13.449219 56 14 56 C 14.550781 56 15 55.554688 15 55 L 15 53 C 15 52.445313 14.550781 52 14 52 Z M 19 52 C 18.449219 52 18 52.445313 18 53 L 18 55 C 18 55.554688 18.449219 56 19 56 C 19.550781 56 20 55.554688 20 55 L 20 53 C 20 52.445313 19.550781 52 19 52 Z M 24 52 C 23.449219 52 23 52.445313 23 53 L 23 55 C 23 55.554688 23.449219 56 24 56 C 24.550781 56 25 55.554688 25 55 L 25 53 C 25 52.445313 24.550781 52 24 52 Z M 29 52 C 28.449219 52 28 52.445313 28 53 L 28 55 C 28 55.554688 28.449219 56 29 56 C 29.550781 56 30 55.554688 30 55 L 30 53 C 30 52.445313 29.550781 52 29 52 Z M 34 52 C 33.449219 52 33 52.445313 33 53 L 33 55 C 33 55.554688 33.449219 56 34 56 C 34.550781 56 35 55.554688 35 55 L 35 53 C 35 52.445313 34.550781 52 34 52 Z M 39 52 C 38.449219 52 38 52.445313 38 53 L 38 55 C 38 55.554688 38.449219 56 39 56 C 39.550781 56 40 55.554688 40 55 L 40 53 C 40 52.445313 39.550781 52 39 52 Z M 44 52 C 43.449219 52 43 52.445313 43 53 L 43 55 C 43 55.554688 43.449219 56 44 56 C 44.550781 56 45 55.554688 45 55 L 45 53 C 45 52.445313 44.550781 52 44 52 Z " }) }) +); +const PdfFileIcon = createSvgIcon( + /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsx("path", { d: "M 17.65625 4 C 16.320313 4 15.066406 4.519531 14.121094 5.464844 L 5.464844 14.121094 C 4.519531 15.066406 4 16.320313 4 17.65625 L 4 57 C 4 58.652344 5.347656 60 7 60 L 47 60 C 48.652344 60 50 58.652344 50 57 L 50 46 L 58 46 C 59.101563 46 60 45.101563 60 44 L 60 24 C 60 22.898438 59.101563 22 58 22 L 50 22 L 50 7 C 50 5.347656 48.652344 4 47 4 Z M 18 6 L 47 6 C 47.550781 6 48 6.449219 48 7 L 48 22 L 16 22 C 14.898438 22 14 22.898438 14 24 L 14 44 C 14 45.101563 14.898438 46 16 46 L 48 46 L 48 57 C 48 57.550781 47.550781 58 47 58 L 7 58 C 6.449219 58 6 57.550781 6 57 L 6 18 L 15 18 C 16.652344 18 18 16.652344 18 15 Z M 16 6.5 L 16 15 C 16 15.550781 15.550781 16 15 16 L 6.5 16 C 6.609375 15.835938 6.734375 15.679688 6.878906 15.535156 L 15.535156 6.878906 C 15.679688 6.734375 15.835938 6.609375 16 6.5 Z M 16 24 L 58 24 L 58 44 L 16 44 Z M 25 28 C 24.445313 28 24 28.449219 24 29 L 24 39 C 24 39.550781 24.445313 40 25 40 C 25.554688 40 26 39.550781 26 39 L 26 36 L 29 36 C 30.652344 36 32 34.652344 32 33 L 32 31 C 32 29.347656 30.652344 28 29 28 Z M 35 28 C 34.445313 28 34 28.449219 34 29 L 34 39 C 34 39.550781 34.445313 40 35 40 L 38 40 C 40.207031 40 42 38.207031 42 36 L 42 32 C 42 29.792969 40.207031 28 38 28 Z M 45 28 C 44.445313 28 44 28.449219 44 29 L 44 39 C 44 39.550781 44.445313 40 45 40 C 45.554688 40 46 39.550781 46 39 L 46 36 L 49 36 C 49.554688 36 50 35.550781 50 35 C 50 34.449219 49.554688 34 49 34 L 46 34 L 46 30 L 50 30 C 50.554688 30 51 29.550781 51 29 C 51 28.449219 50.554688 28 50 28 Z M 26 30 L 29 30 C 29.550781 30 30 30.449219 30 31 L 30 33 C 30 33.550781 29.550781 34 29 34 L 26 34 Z M 36 30 L 38 30 C 39.101563 30 40 30.898438 40 32 L 40 36 C 40 37.101563 39.101563 38 38 38 L 36 38 Z M 9 52 C 8.445313 52 8 52.449219 8 53 L 8 55 C 8 55.550781 8.445313 56 9 56 C 9.554688 56 10 55.550781 10 55 L 10 53 C 10 52.449219 9.554688 52 9 52 Z M 14 52 C 13.445313 52 13 52.449219 13 53 L 13 55 C 13 55.550781 13.445313 56 14 56 C 14.554688 56 15 55.550781 15 55 L 15 53 C 15 52.449219 14.554688 52 14 52 Z M 19 52 C 18.445313 52 18 52.449219 18 53 L 18 55 C 18 55.550781 18.445313 56 19 56 C 19.554688 56 20 55.550781 20 55 L 20 53 C 20 52.449219 19.554688 52 19 52 Z M 24 52 C 23.445313 52 23 52.449219 23 53 L 23 55 C 23 55.550781 23.445313 56 24 56 C 24.554688 56 25 55.550781 25 55 L 25 53 C 25 52.449219 24.554688 52 24 52 Z M 29 52 C 28.445313 52 28 52.449219 28 53 L 28 55 C 28 55.550781 28.445313 56 29 56 C 29.554688 56 30 55.550781 30 55 L 30 53 C 30 52.449219 29.554688 52 29 52 Z M 34 52 C 33.445313 52 33 52.449219 33 53 L 33 55 C 33 55.550781 33.445313 56 34 56 C 34.554688 56 35 55.550781 35 55 L 35 53 C 35 52.449219 34.554688 52 34 52 Z M 39 52 C 38.445313 52 38 52.449219 38 53 L 38 55 C 38 55.550781 38.445313 56 39 56 C 39.554688 56 40 55.550781 40 55 L 40 53 C 40 52.449219 39.554688 52 39 52 Z M 44 52 C 43.445313 52 43 52.449219 43 53 L 43 55 C 43 55.550781 43.445313 56 44 56 C 44.554688 56 45 55.550781 45 55 L 45 53 C 45 52.449219 44.554688 52 44 52 Z " }) }) +); +const ArchiveFileIcon = createSvgIcon( + /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsx("path", { d: "M 21.65625 4 C 20.320313 4 19.066406 4.519531 18.121094 5.464844 L 9.464844 14.121094 C 8.519531 15.066406 8 16.320313 8 17.65625 L 8 57 C 8 58.652344 9.347656 60 11 60 L 51 60 C 52.652344 60 54 58.652344 54 57 L 54 7 C 54 5.347656 52.652344 4 51 4 Z M 22 6 L 36 6 L 36 27.59375 C 35.144531 27.222656 34.210938 27 33.226563 27 L 32.773438 27 C 31.789063 27 30.859375 27.222656 30 27.59375 L 30 9 C 30 8.449219 29.554688 8 29 8 C 28.449219 8 28 8.449219 28 9 L 28 28.902344 C 27.015625 29.824219 26.277344 31.023438 25.953125 32.425781 L 24.875 37.097656 C 24.597656 38.292969 24.878906 39.53125 25.640625 40.488281 C 26.40625 41.449219 27.546875 42 28.769531 42 L 37.230469 42 C 38.457031 42 39.59375 41.449219 40.359375 40.488281 C 41.121094 39.53125 41.402344 38.292969 41.125 37.097656 L 40.046875 32.425781 C 39.726563 31.023438 38.984375 29.824219 38 28.902344 L 38 6 L 51 6 C 51.550781 6 52 6.449219 52 7 L 52 57 C 52 57.550781 51.550781 58 51 58 L 11 58 C 10.449219 58 10 57.550781 10 57 L 10 18 L 19 18 C 20.652344 18 22 16.652344 22 15 Z M 20 6.5 L 20 15 C 20 15.550781 19.550781 16 19 16 L 10.5 16 C 10.609375 15.835938 10.734375 15.679688 10.878906 15.535156 L 19.535156 6.878906 C 19.679688 6.738281 19.835938 6.609375 20 6.5 Z M 32 8 C 31.449219 8 31 8.445313 31 9 C 31 9.554688 31.449219 10 32 10 L 34 10 C 34.550781 10 35 9.554688 35 9 C 35 8.445313 34.550781 8 34 8 Z M 32 13 C 31.449219 13 31 13.445313 31 14 C 31 14.554688 31.449219 15 32 15 L 34 15 C 34.550781 15 35 14.554688 35 14 C 35 13.445313 34.550781 13 34 13 Z M 32 18 C 31.449219 18 31 18.445313 31 19 C 31 19.554688 31.449219 20 32 20 L 34 20 C 34.550781 20 35 19.554688 35 19 C 35 18.445313 34.550781 18 34 18 Z M 32 23 C 31.449219 23 31 23.445313 31 24 C 31 24.554688 31.449219 25 32 25 L 34 25 C 34.550781 25 35 24.554688 35 24 C 35 23.445313 34.550781 23 34 23 Z M 32.773438 29 L 33.226563 29 C 35.570313 29 37.574219 30.59375 38.097656 32.875 L 39.175781 37.550781 C 39.316406 38.148438 39.175781 38.765625 38.796875 39.246094 C 38.414063 39.722656 37.839844 40 37.230469 40 L 28.769531 40 C 28.160156 40 27.589844 39.722656 27.207031 39.246094 C 26.824219 38.765625 26.683594 38.148438 26.824219 37.550781 L 27.902344 32.875 C 28.429688 30.59375 30.429688 29 32.773438 29 Z M 31 34 C 30.449219 34 30 34.445313 30 35 C 30 35.554688 30.449219 36 31 36 L 35 36 C 35.550781 36 36 35.554688 36 35 C 36 34.445313 35.550781 34 35 34 Z M 13 52 C 12.449219 52 12 52.445313 12 53 C 12 53.554688 12.449219 54 13 54 L 17 54 C 17.550781 54 18 53.554688 18 53 C 18 52.445313 17.550781 52 17 52 Z M 21 52 C 20.449219 52 20 52.445313 20 53 C 20 53.554688 20.449219 54 21 54 L 49 54 C 49.550781 54 50 53.554688 50 53 C 50 52.445313 49.550781 52 49 52 Z " }) }) +); +const FolderFileIcon = createSvgIcon( + /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsx("path", { d: "M 5 10 C 3.300781 10 2 11.300781 2 13 L 2 52 C 2 54.199219 3.800781 56 6 56 L 60 56 C 62.199219 56 64 54.199219 64 52 L 64 23 C 64 21.300781 62.699219 20 61 20 L 58 20 L 58 19 C 58 17.300781 56.699219 16 55 16 L 29.699219 16 C 28.898438 16 28.199219 15.699219 27.597656 15.097656 L 23.902344 11.402344 C 23 10.5 21.699219 10 20.402344 10 Z M 5 12 L 20.402344 12 C 21.199219 12 21.898438 12.300781 22.5 12.902344 L 26.199219 16.597656 C 27.097656 17.5 28.398438 18 29.699219 18 L 55 18 C 55.601563 18 56 18.398438 56 19 L 56 52 C 56 52.601563 56.199219 53.300781 56.597656 54 L 6 54 C 4.898438 54 4 53.101563 4 52 L 4 46 L 45 46 C 45.601563 46 46 45.601563 46 45 C 46 44.398438 45.601563 44 45 44 L 4 44 L 4 13 C 4 12.398438 4.398438 12 5 12 Z M 58 22 L 61 22 C 61.601563 22 62 22.398438 62 23 L 62 52 C 62 53.101563 61.101563 54 60 54 C 58.800781 54 58 52.601563 58 52 Z M 11 24 C 10.398438 24 10 24.398438 10 25 C 10 25.601563 10.398438 26 11 26 L 21 26 C 21.601563 26 22 25.601563 22 25 C 22 24.398438 21.601563 24 21 24 Z M 25 24 C 24.398438 24 24 24.398438 24 25 C 24 25.601563 24.398438 26 25 26 L 31 26 C 31.601563 26 32 25.601563 32 25 C 32 24.398438 31.601563 24 31 24 Z M 11 28 C 10.398438 28 10 28.398438 10 29 C 10 29.601563 10.398438 30 11 30 L 15 30 C 15.601563 30 16 29.601563 16 29 C 16 28.398438 15.601563 28 15 28 Z M 19 28 C 18.398438 28 18 28.398438 18 29 C 18 29.601563 18.398438 30 19 30 L 26 30 C 26.601563 30 27 29.601563 27 29 C 27 28.398438 26.601563 28 26 28 Z M 49 44 C 48.398438 44 48 44.398438 48 45 C 48 45.601563 48.398438 46 49 46 L 53 46 C 53.601563 46 54 45.601563 54 45 C 54 44.398438 53.601563 44 53 44 Z M 7 48 C 6.398438 48 6 48.398438 6 49 L 6 51 C 6 51.601563 6.398438 52 7 52 C 7.601563 52 8 51.601563 8 51 L 8 49 C 8 48.398438 7.601563 48 7 48 Z M 12 48 C 11.398438 48 11 48.398438 11 49 L 11 51 C 11 51.601563 11.398438 52 12 52 C 12.601563 52 13 51.601563 13 51 L 13 49 C 13 48.398438 12.601563 48 12 48 Z M 17 48 C 16.398438 48 16 48.398438 16 49 L 16 51 C 16 51.601563 16.398438 52 17 52 C 17.601563 52 18 51.601563 18 51 L 18 49 C 18 48.398438 17.601563 48 17 48 Z M 22 48 C 21.398438 48 21 48.398438 21 49 L 21 51 C 21 51.601563 21.398438 52 22 52 C 22.601563 52 23 51.601563 23 51 L 23 49 C 23 48.398438 22.601563 48 22 48 Z M 27 48 C 26.398438 48 26 48.398438 26 49 L 26 51 C 26 51.601563 26.398438 52 27 52 C 27.601563 52 28 51.601563 28 51 L 28 49 C 28 48.398438 27.601563 48 27 48 Z M 32 48 C 31.398438 48 31 48.398438 31 49 L 31 51 C 31 51.601563 31.398438 52 32 52 C 32.601563 52 33 51.601563 33 51 L 33 49 C 33 48.398438 32.601563 48 32 48 Z M 37 48 C 36.398438 48 36 48.398438 36 49 L 36 51 C 36 51.601563 36.398438 52 37 52 C 37.601563 52 38 51.601563 38 51 L 38 49 C 38 48.398438 37.601563 48 37 48 Z M 42 48 C 41.398438 48 41 48.398438 41 49 L 41 51 C 41 51.601563 41.398438 52 42 52 C 42.601563 52 43 51.601563 43 51 L 43 49 C 43 48.398438 42.601563 48 42 48 Z M 47 48 C 46.398438 48 46 48.398438 46 49 L 46 51 C 46 51.601563 46.398438 52 47 52 C 47.601563 52 48 51.601563 48 51 L 48 49 C 48 48.398438 47.601563 48 47 48 Z M 52 48 C 51.398438 48 51 48.398438 51 49 L 51 51 C 51 51.601563 51.398438 52 52 52 C 52.601563 52 53 51.601563 53 51 L 53 49 C 53 48.398438 52.601563 48 52 48 Z " }) }) +); +const ImageFileIcon = createSvgIcon( + /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsx("path", { d: "M 21.65625 4 C 20.320313 4 19.066406 4.519531 18.121094 5.464844 L 9.464844 14.121094 C 8.519531 15.066406 8 16.320313 8 17.65625 L 8 57 C 8 58.652344 9.347656 60 11 60 L 51 60 C 52.652344 60 54 58.652344 54 57 L 54 7 C 54 5.347656 52.652344 4 51 4 Z M 22 6 L 51 6 C 51.550781 6 52 6.449219 52 7 L 52 57 C 52 57.550781 51.550781 58 51 58 L 11 58 C 10.449219 58 10 57.550781 10 57 L 10 18 L 19 18 C 20.652344 18 22 16.652344 22 15 Z M 20 6.5 L 20 15 C 20 15.550781 19.550781 16 19 16 L 10.5 16 C 10.605469 15.835938 10.734375 15.679688 10.878906 15.535156 L 19.535156 6.878906 C 19.679688 6.738281 19.835938 6.613281 20 6.5 Z M 20 24 C 17.792969 24 16 25.792969 16 28 C 16 30.207031 17.792969 32 20 32 C 22.207031 32 24 30.207031 24 28 C 24 25.792969 22.207031 24 20 24 Z M 20 25.75 C 21.242188 25.75 22.25 26.757813 22.25 28 C 22.25 29.242188 21.242188 30.25 20 30.25 C 18.757813 30.25 17.75 29.242188 17.75 28 C 17.75 26.757813 18.757813 25.75 20 25.75 Z M 37 30.414063 C 36.488281 30.414063 35.976563 30.609375 35.585938 31 L 29 37.585938 L 26.414063 35 C 25.632813 34.21875 24.363281 34.21875 23.585938 35 L 14.585938 44 L 13.042969 44 C 12.417969 44 12 44.398438 12 45 C 12 45.601563 12.523438 46 13.042969 46 L 48.980469 46 C 49.5 46 50.023438 45.601563 50.023438 45 C 50.023438 44.398438 49.5 44 48.980469 44 L 25.414063 44 L 37 32.414063 L 45.292969 40.707031 C 45.683594 41.097656 46.316406 41.097656 46.707031 40.707031 C 47.097656 40.316406 47.097656 39.683594 46.707031 39.292969 L 38.414063 31 C 38.023438 30.609375 37.511719 30.414063 37 30.414063 Z M 25 36.414063 L 27.585938 39 L 22.585938 44 L 17.414063 44 Z M 13 52 C 12.449219 52 12 52.449219 12 53 L 12 55 C 12 55.550781 12.449219 56 13 56 C 13.550781 56 14 55.550781 14 55 L 14 53 C 14 52.449219 13.550781 52 13 52 Z M 18 52 C 17.449219 52 17 52.449219 17 53 L 17 55 C 17 55.550781 17.449219 56 18 56 C 18.550781 56 19 55.550781 19 55 L 19 53 C 19 52.449219 18.550781 52 18 52 Z M 23 52 C 22.449219 52 22 52.449219 22 53 L 22 55 C 22 55.550781 22.449219 56 23 56 C 23.550781 56 24 55.550781 24 55 L 24 53 C 24 52.449219 23.550781 52 23 52 Z M 28 52 C 27.449219 52 27 52.449219 27 53 L 27 55 C 27 55.550781 27.449219 56 28 56 C 28.550781 56 29 55.550781 29 55 L 29 53 C 29 52.449219 28.550781 52 28 52 Z M 33 52 C 32.449219 52 32 52.449219 32 53 L 32 55 C 32 55.550781 32.449219 56 33 56 C 33.550781 56 34 55.550781 34 55 L 34 53 C 34 52.449219 33.550781 52 33 52 Z M 38 52 C 37.449219 52 37 52.449219 37 53 L 37 55 C 37 55.550781 37.449219 56 38 56 C 38.550781 56 39 55.550781 39 55 L 39 53 C 39 52.449219 38.550781 52 38 52 Z M 43 52 C 42.449219 52 42 52.449219 42 53 L 42 55 C 42 55.550781 42.449219 56 43 56 C 43.550781 56 44 55.550781 44 55 L 44 53 C 44 52.449219 43.550781 52 43 52 Z M 48 52 C 47.449219 52 47 52.449219 47 53 L 47 55 C 47 55.550781 47.449219 56 48 56 C 48.550781 56 49 55.550781 49 55 L 49 53 C 49 52.449219 48.550781 52 48 52 Z " }) }) +); +const PowerPointFileIcon = createSvgIcon( + /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsx("path", { d: "M 35.136719 2.386719 C 34.917969 2.378906 34.699219 2.390625 34.480469 2.429688 L 5.304688 7.578125 C 3.390625 7.917969 2 9.574219 2 11.515625 L 2 50.484375 C 2 52.429688 3.390625 54.085938 5.304688 54.421875 L 34.480469 59.570313 C 34.652344 59.601563 34.828125 59.613281 35 59.613281 C 35.703125 59.613281 36.382813 59.371094 36.925781 58.914063 C 37.609375 58.34375 38 57.503906 38 56.613281 L 38 52 L 57 52 C 58.652344 52 60 50.652344 60 49 L 60 13 C 60 11.347656 58.652344 10 57 10 L 38 10 L 38 5.382813 C 38 4.496094 37.609375 3.65625 36.925781 3.085938 C 36.417969 2.65625 35.789063 2.414063 35.136719 2.386719 Z M 35.105469 4.390625 C 35.359375 4.414063 35.542969 4.535156 35.640625 4.617188 C 35.777344 4.730469 36 4.980469 36 5.382813 L 36 56.613281 C 36 57.019531 35.777344 57.269531 35.640625 57.382813 C 35.507813 57.496094 35.226563 57.667969 34.828125 57.601563 L 5.652344 52.453125 C 4.695313 52.285156 4 51.457031 4 50.484375 L 4 11.515625 C 4 10.542969 4.695313 9.714844 5.652344 9.546875 L 34.824219 4.398438 C 34.925781 4.382813 35.019531 4.378906 35.105469 4.390625 Z M 38 12 L 57 12 C 57.550781 12 58 12.449219 58 13 L 58 49 C 58 49.550781 57.550781 50 57 50 L 38 50 L 38 45.949219 L 52.949219 45.949219 C 53.5 45.949219 53.949219 45.554688 53.949219 45 C 53.949219 44.445313 53.5 44 52.949219 44 L 50 44 L 50 41 C 50 40.445313 49.550781 40 49 40 L 46 40 L 46 37 C 46 36.445313 45.550781 36 45 36 L 41 36 C 40.449219 36 40 36.445313 40 37 L 40 39 L 38 39 L 38 32.46875 C 39.46875 33.449219 41.203125 34 43 34 C 47.960938 34 52 29.964844 52 25 C 52 20.035156 47.960938 16 43 16 C 41.1875 16 39.464844 16.535156 38 17.519531 Z M 42 18.078125 L 42 24.832031 C 42 25.027344 42.070313 25.203125 42.171875 25.359375 C 42.21875 25.492188 42.289063 25.617188 42.394531 25.726563 L 47.234375 30.5625 C 46.054688 31.460938 44.589844 32 43 32 C 41.113281 32 39.316406 31.230469 38 29.886719 L 38 20.105469 C 39.089844 18.992188 40.484375 18.292969 42 18.078125 Z M 44 18.078125 C 47.386719 18.566406 50 21.480469 50 25 C 50 26.546875 49.488281 27.976563 48.636719 29.136719 L 44 24.5 Z M 15 20 C 14.449219 20 14 20.445313 14 21 L 14 41 C 14 41.554688 14.449219 42 15 42 C 15.550781 42 16 41.554688 16 41 L 16 34 L 21 34 C 23.757813 34 26 31.757813 26 29 L 26 25 C 26 22.242188 23.757813 20 21 20 Z M 16 22 L 21 22 C 22.652344 22 24 23.347656 24 25 L 24 29 C 24 30.652344 22.652344 32 21 32 L 16 32 Z M 42 38 L 44 38 L 44 44 L 42 44 Z M 38 41 L 40 41 L 40 44 L 38 44 Z M 46 42 L 48 42 L 48 44 L 46 44 Z " }) }) +); +const WordFileIcon = createSvgIcon( + /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsx("path", { d: "M 21.65625 4 C 20.320313 4 19.0625 4.519531 18.121094 5.464844 L 9.464844 14.121094 C 8.519531 15.066406 8 16.320313 8 17.65625 L 8 57 C 8 58.652344 9.347656 60 11 60 L 51 60 C 52.652344 60 54 58.652344 54 57 L 54 7 C 54 5.347656 52.652344 4 51 4 Z M 22 6 L 51 6 C 51.550781 6 52 6.449219 52 7 L 52 57 C 52 57.550781 51.550781 58 51 58 L 11 58 C 10.449219 58 10 57.550781 10 57 L 10 18 L 19 18 C 20.652344 18 22 16.652344 22 15 Z M 20 6.5 L 20 15 C 20 15.550781 19.550781 16 19 16 L 10.5 16 C 10.613281 15.832031 10.738281 15.675781 10.878906 15.535156 L 19.535156 6.878906 C 19.679688 6.734375 19.835938 6.609375 20 6.5 Z M 21.140625 23.011719 C 21.015625 22.992188 20.878906 22.996094 20.746094 23.03125 C 20.210938 23.175781 19.894531 23.722656 20.03125 24.253906 L 25.03125 43.253906 C 25.148438 43.691406 25.539063 43.996094 25.984375 44 L 26 44 C 26.441406 44 26.832031 43.710938 26.957031 43.28125 L 31 29.546875 L 35.042969 43.28125 C 35.167969 43.707031 35.558594 44 36 44 L 36.015625 44 C 36.460938 43.992188 36.851563 43.6875 36.96875 43.253906 L 41.96875 24.253906 C 42.105469 23.722656 41.789063 23.175781 41.253906 23.03125 C 40.71875 22.890625 40.171875 23.210938 40.03125 23.746094 L 35.945313 39.273438 L 31.957031 25.71875 C 31.832031 25.292969 31.445313 25 31 25 C 30.554688 25 30.167969 25.292969 30.042969 25.71875 L 26.054688 39.277344 L 21.96875 23.746094 C 21.863281 23.347656 21.527344 23.066406 21.140625 23.011719 Z M 13 52 C 12.449219 52 12 52.445313 12 53 L 12 55 C 12 55.554688 12.449219 56 13 56 C 13.550781 56 14 55.554688 14 55 L 14 53 C 14 52.445313 13.550781 52 13 52 Z M 18 52 C 17.449219 52 17 52.445313 17 53 L 17 55 C 17 55.554688 17.449219 56 18 56 C 18.550781 56 19 55.554688 19 55 L 19 53 C 19 52.445313 18.550781 52 18 52 Z M 23 52 C 22.449219 52 22 52.445313 22 53 L 22 55 C 22 55.554688 22.449219 56 23 56 C 23.550781 56 24 55.554688 24 55 L 24 53 C 24 52.445313 23.550781 52 23 52 Z M 28 52 C 27.449219 52 27 52.445313 27 53 L 27 55 C 27 55.554688 27.449219 56 28 56 C 28.550781 56 29 55.554688 29 55 L 29 53 C 29 52.445313 28.550781 52 28 52 Z M 33 52 C 32.449219 52 32 52.445313 32 53 L 32 55 C 32 55.554688 32.449219 56 33 56 C 33.550781 56 34 55.554688 34 55 L 34 53 C 34 52.445313 33.550781 52 33 52 Z M 38 52 C 37.449219 52 37 52.445313 37 53 L 37 55 C 37 55.554688 37.449219 56 38 56 C 38.550781 56 39 55.554688 39 55 L 39 53 C 39 52.445313 38.550781 52 38 52 Z M 43 52 C 42.449219 52 42 52.445313 42 53 L 42 55 C 42 55.554688 42.449219 56 43 56 C 43.550781 56 44 55.554688 44 55 L 44 53 C 44 52.445313 43.550781 52 43 52 Z M 48 52 C 47.449219 52 47 52.445313 47 53 L 47 55 C 47 55.554688 47.449219 56 48 56 C 48.550781 56 49 55.554688 49 55 L 49 53 C 49 52.445313 48.550781 52 48 52 Z " }) }) +); +const SpreadsheetFileIcon = createSvgIcon( + /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsx("path", { d: "M 35.136719 2.386719 C 34.917969 2.378906 34.699219 2.390625 34.480469 2.429688 L 5.304688 7.578125 C 3.390625 7.917969 2 9.574219 2 11.515625 L 2 50.484375 C 2 52.429688 3.390625 54.085938 5.304688 54.421875 L 34.480469 59.570313 C 34.652344 59.601563 34.828125 59.613281 35 59.613281 C 35.703125 59.613281 36.382813 59.371094 36.925781 58.914063 C 37.609375 58.34375 38 57.503906 38 56.613281 L 38 52 L 57 52 C 58.652344 52 60 50.652344 60 49 L 60 13 C 60 11.347656 58.652344 10 57 10 L 38 10 L 38 5.382813 C 38 4.496094 37.609375 3.65625 36.925781 3.085938 C 36.417969 2.65625 35.789063 2.414063 35.136719 2.386719 Z M 35.105469 4.390625 C 35.359375 4.414063 35.542969 4.535156 35.640625 4.617188 C 35.777344 4.730469 36 4.980469 36 5.382813 L 36 56.613281 C 36 57.019531 35.777344 57.269531 35.640625 57.382813 C 35.507813 57.496094 35.226563 57.671875 34.828125 57.601563 L 5.652344 52.453125 C 4.695313 52.285156 4 51.457031 4 50.484375 L 4 11.515625 C 4 10.542969 4.695313 9.714844 5.652344 9.546875 L 34.824219 4.398438 C 34.925781 4.382813 35.019531 4.378906 35.105469 4.390625 Z M 38 12 L 57 12 C 57.550781 12 58 12.449219 58 13 L 58 49 C 58 49.550781 57.550781 50 57 50 L 38 50 L 38 44 L 41 44 C 41.550781 44 42 43.554688 42 43 C 42 42.445313 41.550781 42 41 42 L 38 42 L 38 38 L 41 38 C 41.550781 38 42 37.554688 42 37 C 42 36.445313 41.550781 36 41 36 L 38 36 L 38 32 L 41 32 C 41.550781 32 42 31.554688 42 31 C 42 30.445313 41.550781 30 41 30 L 38 30 L 38 26 L 41 26 C 41.550781 26 42 25.554688 42 25 C 42 24.445313 41.550781 24 41 24 L 38 24 L 38 20 L 41 20 C 41.550781 20 42 19.554688 42 19 C 42 18.445313 41.550781 18 41 18 L 38 18 Z M 45 18 C 44.449219 18 44 18.445313 44 19 C 44 19.554688 44.449219 20 45 20 L 51 20 C 51.550781 20 52 19.554688 52 19 C 52 18.445313 51.550781 18 51 18 Z M 12.824219 20.015625 C 12.695313 20.039063 12.570313 20.085938 12.453125 20.160156 C 11.992188 20.460938 11.859375 21.082031 12.160156 21.546875 L 18.308594 31 L 12.160156 40.453125 C 11.859375 40.917969 11.992188 41.539063 12.453125 41.839844 C 12.625 41.949219 12.8125 42 13 42 C 13.324219 42 13.648438 41.839844 13.839844 41.546875 L 19.5 32.835938 L 25.160156 41.546875 C 25.351563 41.839844 25.675781 42 26 42 C 26.1875 42 26.375 41.949219 26.546875 41.839844 C 27.007813 41.539063 27.140625 40.917969 26.839844 40.453125 L 20.691406 31 L 26.839844 21.546875 C 27.140625 21.082031 27.007813 20.460938 26.546875 20.160156 C 26.082031 19.859375 25.460938 19.992188 25.160156 20.453125 L 19.5 29.164063 L 13.839844 20.453125 C 13.613281 20.105469 13.207031 19.945313 12.824219 20.015625 Z M 45 24 C 44.449219 24 44 24.445313 44 25 C 44 25.554688 44.449219 26 45 26 L 51 26 C 51.550781 26 52 25.554688 52 25 C 52 24.445313 51.550781 24 51 24 Z M 45 30 C 44.449219 30 44 30.445313 44 31 C 44 31.554688 44.449219 32 45 32 L 51 32 C 51.550781 32 52 31.554688 52 31 C 52 30.445313 51.550781 30 51 30 Z M 45 36 C 44.449219 36 44 36.445313 44 37 C 44 37.554688 44.449219 38 45 38 L 51 38 C 51.550781 38 52 37.554688 52 37 C 52 36.445313 51.550781 36 51 36 Z M 45 42 C 44.449219 42 44 42.445313 44 43 C 44 43.554688 44.449219 44 45 44 L 51 44 C 51.550781 44 52 43.554688 52 43 C 52 42.445313 51.550781 42 51 42 Z " }) }) +); +const SharedFolderFileIcon = createSvgIcon( + /* @__PURE__ */ jsx("g", { children: /* @__PURE__ */ jsx("path", { d: "M 3 8 C 1.347656 8 0 9.347656 0 11 L 0 52 C 0 54.207031 1.792969 56 4 56 L 58 56 C 60.207031 56 62 54.207031 62 52 L 62 21 C 62 19.347656 60.652344 18 59 18 L 56 18 L 56 17 C 56 15.347656 54.652344 14 53 14 L 27.707031 14 C 26.910156 14 26.164063 13.691406 25.597656 13.132813 L 21.875 9.445313 C 20.929688 8.515625 19.679688 8 18.355469 8 Z M 3 10 L 18.355469 10 C 19.152344 10 19.898438 10.308594 20.464844 10.867188 L 24.1875 14.554688 C 25.132813 15.484375 26.382813 16 27.707031 16 L 53 16 C 53.550781 16 54 16.449219 54 17 L 54 52 C 54 52.617188 54.222656 53.339844 54.632813 54 L 4 54 C 2.898438 54 2 53.101563 2 52 L 2 46 L 43 46 C 43.550781 46 44 45.550781 44 45 C 44 44.449219 43.550781 44 43 44 L 2 44 L 2 11 C 2 10.449219 2.449219 10 3 10 Z M 56 20 L 59 20 C 59.550781 20 60 20.449219 60 21 L 60 52 C 60 53.101563 59.101563 54 58 54 C 56.753906 54 56 52.609375 56 52 Z M 27 22 C 24.242188 22 22 24.242188 22 27 L 22 29 C 22 29.992188 22.300781 30.914063 22.800781 31.691406 C 20.058594 32.886719 17.882813 35.527344 17.28125 38.765625 C 17.179688 39.3125 17.539063 39.832031 18.082031 39.933594 C 18.625 40.035156 19.148438 39.675781 19.25 39.132813 C 19.785156 36.242188 21.863281 33.949219 24.371094 33.234375 C 25.136719 33.710938 26.03125 34 27 34 C 27.96875 34 28.863281 33.710938 29.628906 33.234375 C 32.136719 33.949219 34.214844 36.246094 34.75 39.136719 C 34.839844 39.617188 35.261719 39.953125 35.734375 39.953125 C 35.796875 39.953125 35.855469 39.949219 35.917969 39.9375 C 36.460938 39.835938 36.820313 39.3125 36.71875 38.769531 C 36.117188 35.53125 33.941406 32.886719 31.199219 31.691406 C 31.699219 30.914063 32 29.992188 32 29 L 32 27 C 32 24.242188 29.757813 22 27 22 Z M 27 24 C 28.652344 24 30 25.347656 30 27 L 30 29 C 30 30.652344 28.652344 32 27 32 C 25.347656 32 24 30.652344 24 29 L 24 27 C 24 25.347656 25.347656 24 27 24 Z M 47 44 C 46.449219 44 46 44.449219 46 45 C 46 45.550781 46.449219 46 47 46 L 51 46 C 51.550781 46 52 45.550781 52 45 C 52 44.449219 51.550781 44 51 44 Z M 5 48 C 4.449219 48 4 48.449219 4 49 L 4 51 C 4 51.550781 4.449219 52 5 52 C 5.550781 52 6 51.550781 6 51 L 6 49 C 6 48.449219 5.550781 48 5 48 Z M 10 48 C 9.449219 48 9 48.449219 9 49 L 9 51 C 9 51.550781 9.449219 52 10 52 C 10.550781 52 11 51.550781 11 51 L 11 49 C 11 48.449219 10.550781 48 10 48 Z M 15 48 C 14.449219 48 14 48.449219 14 49 L 14 51 C 14 51.550781 14.449219 52 15 52 C 15.550781 52 16 51.550781 16 51 L 16 49 C 16 48.449219 15.550781 48 15 48 Z M 20 48 C 19.449219 48 19 48.449219 19 49 L 19 51 C 19 51.550781 19.449219 52 20 52 C 20.550781 52 21 51.550781 21 51 L 21 49 C 21 48.449219 20.550781 48 20 48 Z M 25 48 C 24.449219 48 24 48.449219 24 49 L 24 51 C 24 51.550781 24.449219 52 25 52 C 25.550781 52 26 51.550781 26 51 L 26 49 C 26 48.449219 25.550781 48 25 48 Z M 30 48 C 29.449219 48 29 48.449219 29 49 L 29 51 C 29 51.550781 29.449219 52 30 52 C 30.550781 52 31 51.550781 31 51 L 31 49 C 31 48.449219 30.550781 48 30 48 Z M 35 48 C 34.449219 48 34 48.449219 34 49 L 34 51 C 34 51.550781 34.449219 52 35 52 C 35.550781 52 36 51.550781 36 51 L 36 49 C 36 48.449219 35.550781 48 35 48 Z M 40 48 C 39.449219 48 39 48.449219 39 49 L 39 51 C 39 51.550781 39.449219 52 40 52 C 40.550781 52 41 51.550781 41 51 L 41 49 C 41 48.449219 40.550781 48 40 48 Z M 45 48 C 44.449219 48 44 48.449219 44 49 L 44 51 C 44 51.550781 44.449219 52 45 52 C 45.550781 52 46 51.550781 46 51 L 46 49 C 46 48.449219 45.550781 48 45 48 Z M 50 48 C 49.449219 48 49 48.449219 49 49 L 49 51 C 49 51.550781 49.449219 52 50 52 C 50.550781 52 51 51.550781 51 51 L 51 49 C 51 48.449219 50.550781 48 50 48 Z " }) }) +); +function FileTypeIcon({ type, mime, className, size }) { + if (!type && mime) { + type = mime.split("/")[0]; + } + const Icon = FileTypeIcons[type] || FileTypeIcons.default; + return /* @__PURE__ */ jsx( + Icon, + { + size, + className: clsx(className, `${type}-file-color`), + viewBox: "0 0 64 64" + } + ); +} +const FileTypeIcons = { + default: DefaultFileIcon, + audio: AudioFileIcon, + video: VideoFileIcon, + text: TextFileIcon, + pdf: PdfFileIcon, + archive: ArchiveFileIcon, + folder: FolderFileIcon, + sharedFolder: SharedFolderFileIcon, + image: ImageFileIcon, + powerPoint: PowerPointFileIcon, + word: WordFileIcon, + spreadsheet: SpreadsheetFileIcon +}; +const TwoMB$1 = 2 * 1024 * 1024; +function FileThumbnail({ + file, + className, + iconClassName, + showImage = true +}) { + const { trans } = useTrans(); + const { previewUrl } = useFileEntryUrls(file, { thumbnail: true }); + if (file.file_size && file.file_size > TwoMB$1 && !file.thumbnail) { + showImage = false; + } + if (showImage && file.type === "image" && previewUrl) { + const alt = trans({ + message: ":fileName thumbnail", + values: { fileName: file.name } + }); + return /* @__PURE__ */ jsx( + "img", + { + className: clsx(className, "object-cover"), + src: previewUrl, + alt, + draggable: false + } + ); + } + return /* @__PURE__ */ jsx(FileTypeIcon, { className: iconClassName, type: file.type }); +} +function FilePreviewContainer({ + entries, + onClose, + showHeader = true, + className, + headerActionsLeft, + allowDownload = true, + ...props +}) { + const isMobile = useMediaQuery("(max-width: 1024px)"); + const [activeIndex, setActiveIndex] = useControlledState( + props.activeIndex, + props.defaultActiveIndex || 0, + props.onActiveIndexChange + ); + const activeEntry = entries[activeIndex]; + const contextValue = useMemo(() => { + return { entries, activeIndex }; + }, [entries, activeIndex]); + const Preview = getPreviewForEntry(activeEntry); + if (!activeEntry) { + onClose == null ? void 0 : onClose(); + return null; + } + const canOpenNext = entries.length - 1 > activeIndex; + const openNext = () => { + setActiveIndex(activeIndex + 1); + }; + const canOpenPrevious = activeIndex > 0; + const openPrevious = () => { + setActiveIndex(activeIndex - 1); + }; + return /* @__PURE__ */ jsxs(FilePreviewContext.Provider, { value: contextValue, children: [ + showHeader && /* @__PURE__ */ jsx( + Header$2, + { + actionsLeft: headerActionsLeft, + isMobile, + onClose, + onNext: canOpenNext ? openNext : void 0, + onPrevious: canOpenPrevious ? openPrevious : void 0, + allowDownload + } + ), + /* @__PURE__ */ jsxs("div", { className: clsx("overflow-hidden relative flex-auto", className), children: [ + isMobile && /* @__PURE__ */ jsx( + IconButton, + { + size: "lg", + className: "text-muted absolute left-0 top-1/2 transform -translate-y-1/2 z-10", + disabled: !canOpenPrevious, + onClick: openPrevious, + children: /* @__PURE__ */ jsx(KeyboardArrowLeftIcon, {}) + } + ), + /* @__PURE__ */ jsx(AnimatePresence, { initial: false, children: /* @__PURE__ */ jsx( + m.div, + { + className: "absolute inset-0 flex items-center justify-center", + ...opacityAnimation, + children: /* @__PURE__ */ jsx( + Preview, + { + className: "max-h-[calc(100%-30px)]", + entry: activeEntry, + allowDownload + } + ) + }, + activeEntry.id + ) }), + isMobile && /* @__PURE__ */ jsx( + IconButton, + { + size: "lg", + className: "text-muted absolute right-0 top-1/2 transform -translate-y-1/2 z-10", + disabled: !canOpenNext, + onClick: openNext, + children: /* @__PURE__ */ jsx(KeyboardArrowRightIcon, {}) + } + ) + ] }) + ] }); +} +function Header$2({ + onNext, + onPrevious, + onClose, + isMobile, + actionsLeft, + allowDownload +}) { + const { entries, activeIndex } = useContext(FilePreviewContext); + const activeEntry = entries[activeIndex]; + const { downloadUrl } = useFileEntryUrls(activeEntry); + const desktopDownloadButton = /* @__PURE__ */ jsx( + Button, + { + startIcon: /* @__PURE__ */ jsx(FileDownloadIcon, {}), + variant: "text", + onClick: () => { + if (downloadUrl) { + downloadFileFromUrl(downloadUrl); + } + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Download" }) + } + ); + const mobileDownloadButton = /* @__PURE__ */ jsx( + IconButton, + { + onClick: () => { + if (downloadUrl) { + downloadFileFromUrl(downloadUrl); + } + }, + children: /* @__PURE__ */ jsx(FileDownloadIcon, {}) + } + ); + const downloadButton = isMobile ? mobileDownloadButton : desktopDownloadButton; + return /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-20 bg-paper border-b flex-shrink-0 text-sm min-h-50 px-10 text-muted", children: [ + /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4 w-1/3 justify-start", children: [ + actionsLeft, + allowDownload ? downloadButton : void 0 + ] }), + /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-10 w-1/3 justify-center flex-nowrap text-main", children: [ + /* @__PURE__ */ jsx( + FileThumbnail, + { + file: activeEntry, + iconClassName: "w-16 h-16", + showImage: false + } + ), + /* @__PURE__ */ jsx("div", { className: "whitespace-nowrap overflow-hidden overflow-ellipsis", children: activeEntry.name }) + ] }), + /* @__PURE__ */ jsxs("div", { className: "w-1/3 flex items-center gap-10 justify-end whitespace-nowrap", children: [ + !isMobile && /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(IconButton, { disabled: !onPrevious, onClick: onPrevious, children: /* @__PURE__ */ jsx(ChevronLeftIcon, {}) }), + /* @__PURE__ */ jsx("div", { children: activeIndex + 1 }), + /* @__PURE__ */ jsx("div", { children: "/" }), + /* @__PURE__ */ jsx("div", { children: entries.length }), + /* @__PURE__ */ jsx(IconButton, { disabled: !onNext, onClick: onNext, children: /* @__PURE__ */ jsx(ChevronRightIcon, {}) }), + /* @__PURE__ */ jsx("div", { className: "bg-divider w-1 h-24 mx-20" }) + ] }), + /* @__PURE__ */ jsx(IconButton, { radius: "rounded-none", onClick: onClose, children: /* @__PURE__ */ jsx(CloseIcon, {}) }) + ] }) + ] }); +} +function FilePreviewDialog(props) { + return /* @__PURE__ */ jsx( + Dialog, + { + size: "fullscreenTakeover", + background: "bg-alt", + className: "flex flex-col", + children: /* @__PURE__ */ jsx(Content$1, { ...props }) + } + ); +} +function Content$1(props) { + const { close } = useDialogContext(); + return /* @__PURE__ */ jsx(FilePreviewContainer, { onClose: close, ...props }); +} +const FILE_ENTRY_TYPE_FILTER = { + key: "type", + label: message("Type"), + description: message("Type of the file"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "05", + options: [ + { key: "02", label: message("Text"), value: "text" }, + { + key: "03", + label: message("Audio"), + value: "audio" + }, + { + key: "04", + label: message("Video"), + value: "video" + }, + { + key: "05", + label: message("Image"), + value: "image" + }, + { key: "06", label: message("PDF"), value: "pdf" }, + { + key: "07", + label: message("Spreadsheet"), + value: "spreadsheet" + }, + { + key: "08", + label: message("Word Document"), + value: "word" + }, + { + key: "09", + label: message("Photoshop"), + value: "photoshop" + }, + { + key: "10", + label: message("Archive"), + value: "archive" + }, + { + key: "11", + label: message("Folder"), + value: "folder" + } + ] + } +}; +const FILE_ENTRY_INDEX_FILTERS = [ + FILE_ENTRY_TYPE_FILTER, + { + key: "public", + label: message("Visibility"), + description: message("Whether file is publicly accessible"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "01", + options: [ + { key: "01", label: message("Private"), value: false }, + { key: "02", label: message("Public"), value: true } + ] + } + }, + createdAtFilter({ + description: message("Date file was uploaded") + }), + updatedAtFilter({ + description: message("Date file was last changed") + }), + { + key: "owner_id", + label: message("Uploader"), + description: message("User that this file was uploaded by"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.SelectModel, + model: USER_MODEL + } + } +]; +const columnConfig$7 = [ + { + key: "name", + allowsSorting: true, + visibleInMode: "all", + width: "flex-3 min-w-200", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Name" }), + body: (entry) => /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx("div", { className: "overflow-x-hidden overflow-ellipsis", children: entry.name }), + /* @__PURE__ */ jsx("div", { className: "text-muted text-xs overflow-x-hidden overflow-ellipsis", children: entry.file_name }) + ] }) + }, + { + key: "owner_id", + allowsSorting: true, + width: "flex-3 min-w-200", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Uploader" }), + body: (entry) => { + var _a2; + const user = (_a2 = entry.users) == null ? void 0 : _a2[0]; + if (!user) + return null; + return /* @__PURE__ */ jsx( + NameWithAvatar, + { + image: user.avatar, + label: user.display_name, + description: user.email + } + ); + } + }, + { + key: "type", + width: "w-100 flex-shrink-0", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Type" }), + body: (entry) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-12", children: [ + /* @__PURE__ */ jsx(FileTypeIcon, { type: entry.type, className: "w-24 h-24 overflow-hidden" }), + /* @__PURE__ */ jsx("div", { className: "capitalize", children: entry.type }) + ] }) + }, + { + key: "public", + allowsSorting: true, + width: "w-60 flex-shrink-0", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Public" }), + body: (entry) => entry.public ? /* @__PURE__ */ jsx(CheckIcon, { className: "icon-md text-positive" }) : /* @__PURE__ */ jsx(CloseIcon, { className: "icon-md text-danger" }) + }, + { + key: "file_size", + allowsSorting: true, + maxWidth: "max-w-100", + header: () => /* @__PURE__ */ jsx(Trans, { message: "File size" }), + body: (entry) => /* @__PURE__ */ jsx(FormattedBytes, { bytes: entry.file_size }) + }, + { + key: "updated_at", + allowsSorting: true, + width: "w-100", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (entry) => /* @__PURE__ */ jsx(FormattedDate, { date: entry.updated_at }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + align: "end", + width: "w-42 flex-shrink-0", + visibleInMode: "all", + body: (entry) => { + return /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(IconButton, { size: "md", className: "text-muted", children: /* @__PURE__ */ jsx(VisibilityIcon, {}) }), + /* @__PURE__ */ jsx(FilePreviewDialog, { entries: [entry] }) + ] }); + } + } +]; +function FileEntryIndexPage() { + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "file-entries", + title: /* @__PURE__ */ jsx(Trans, { message: "Uploaded files and folders" }), + columns: columnConfig$7, + filters: FILE_ENTRY_INDEX_FILTERS, + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: uploadSvg, + title: /* @__PURE__ */ jsx(Trans, { message: "Nothing has been uploaded yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching files or folders" }) + } + ) + } + ); +} +const SubscriptionIndexPageFilters = [ + { + key: "ends_at", + label: message("Status"), + description: message("Whether subscription is active or cancelled"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "active", + options: [ + { + key: "active", + label: message("Active"), + value: { value: null, operator: FilterOperator.eq } + }, + { + key: "cancelled", + label: message("Cancelled"), + value: { value: null, operator: FilterOperator.ne } + } + ] + } + }, + { + control: { + type: FilterControlType.Select, + defaultValue: "stripe", + options: [ + { + key: "stripe", + label: message("Stripe"), + value: "stripe" + }, + { + key: "paypal", + label: message("PayPal"), + value: "paypal" + }, + { + key: "none", + label: message("None"), + value: "none" + } + ] + }, + key: "gateway_name", + label: message("Gateway"), + description: message( + "With which payment provider was subscription created" + ), + defaultOperator: FilterOperator.eq + }, + timestampFilter({ + key: "renews_at", + label: message("Renew date"), + description: message("Date subscription will renew") + }), + createdAtFilter({ + description: message("Date subscription was created") + }), + updatedAtFilter({ + description: message("Date subscription was last updated") + }) +]; +const subscriptionsSvg = "/assets/subscriptions-7eacea42.svg"; +function useUpdateSubscription(form) { + const { trans } = useTrans(); + return useMutation({ + mutationFn: (props) => updateSubscription(props), + onSuccess: () => { + toast(trans(message("Subscription updated"))); + queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("billing/subscriptions") + }); + }, + onError: (err) => onFormQueryError(err, form) + }); +} +function updateSubscription({ + id, + ...payload +}) { + return apiClient.put(`billing/subscriptions/${id}`, payload).then((r) => r.data); +} +function CrupdateSubscriptionForm({ + form, + onSubmit, + formId +}) { + var _a2, _b; + const query = useProducts(); + const watchedProductId = form.watch("product_id"); + const selectedProduct = (_a2 = query.data) == null ? void 0 : _a2.products.find( + (p) => p.id === watchedProductId + ); + return /* @__PURE__ */ jsxs(Form$1, { id: formId, form, onSubmit, children: [ + /* @__PURE__ */ jsx( + FormNormalizedModelField, + { + name: "user_id", + className: "mb-20", + endpoint: "normalized-models/user", + label: /* @__PURE__ */ jsx(Trans, { message: "User" }) + } + ), + /* @__PURE__ */ jsx( + FormSelect, + { + name: "product_id", + selectionMode: "single", + className: "mb-20", + label: /* @__PURE__ */ jsx(Trans, { message: "Plan" }), + children: (_b = query.data) == null ? void 0 : _b.products.filter((p) => !p.free).map((product) => /* @__PURE__ */ jsx(Item, { value: product.id, children: /* @__PURE__ */ jsx(Trans, { message: product.name }) }, product.id)) + } + ), + !(selectedProduct == null ? void 0 : selectedProduct.free) && /* @__PURE__ */ jsx( + FormSelect, + { + name: "price_id", + selectionMode: "single", + className: "mb-20", + label: /* @__PURE__ */ jsx(Trans, { message: "Price" }), + children: selectedProduct == null ? void 0 : selectedProduct.prices.map((price) => /* @__PURE__ */ jsx(Item, { value: price.id, children: /* @__PURE__ */ jsx(FormattedPrice, { price }) }, price.id)) + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + inputElementType: "textarea", + rows: 3, + name: "description", + label: /* @__PURE__ */ jsx(Trans, { message: "Description" }), + className: "mb-20" + } + ), + /* @__PURE__ */ jsx( + FormDatePicker, + { + className: "mb-20", + name: "renews_at", + granularity: "day", + label: /* @__PURE__ */ jsx(Trans, { message: "Renews at" }), + description: /* @__PURE__ */ jsx(Trans, { message: "This will only change local records. User will continue to be billed on their original cycle on the payment gateway." }) + } + ), + /* @__PURE__ */ jsx( + FormDatePicker, + { + className: "mb-20", + name: "ends_at", + granularity: "day", + label: /* @__PURE__ */ jsx(Trans, { message: "Ends at" }), + description: /* @__PURE__ */ jsx(Trans, { message: "This will only change local records. User will continue to be billed on their original cycle on the payment gateway." }) + } + ) + ] }); +} +function UpdateSubscriptionDialog({ + subscription +}) { + const { close, formId } = useDialogContext(); + const form = useForm({ + defaultValues: { + id: subscription.id, + product_id: subscription.product_id, + price_id: subscription.price_id, + description: subscription.description, + renews_at: subscription.renews_at, + ends_at: subscription.ends_at, + user_id: subscription.user_id + } + }); + const updateSubscription2 = useUpdateSubscription(form); + return /* @__PURE__ */ jsxs(Dialog, { size: "md", children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Update subscription" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsx( + CrupdateSubscriptionForm, + { + formId, + form, + onSubmit: (values) => { + updateSubscription2.mutate(values, { + onSuccess: () => { + close(); + } + }); + } + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx( + Button, + { + onClick: () => { + close(); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) + } + ), + /* @__PURE__ */ jsx( + Button, + { + form: formId, + disabled: updateSubscription2.isPending, + variant: "flat", + color: "primary", + type: "submit", + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ) + ] }) + ] }); +} +const endpoint$5 = "billing/subscriptions"; +function useCreateSubscription(form) { + const { trans } = useTrans(); + return useMutation({ + mutationFn: (props) => createNewSubscription(props), + onSuccess: () => { + toast(trans(message("Subscription created"))); + queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey(endpoint$5) + }); + }, + onError: (err) => onFormQueryError(err, form) + }); +} +function createNewSubscription(payload) { + return apiClient.post(endpoint$5, payload).then((r) => r.data); +} +function CreateSubscriptionDialog() { + const { close, formId } = useDialogContext(); + const form = useForm({}); + const createSubscription = useCreateSubscription(form); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Add new subscription" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsx( + CrupdateSubscriptionForm, + { + formId, + form, + onSubmit: (values) => { + createSubscription.mutate(values, { + onSuccess: () => { + close(); + } + }); + } + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx( + Button, + { + onClick: () => { + close(); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) + } + ), + /* @__PURE__ */ jsx( + Button, + { + form: formId, + disabled: createSubscription.isPending, + variant: "flat", + color: "primary", + type: "submit", + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ) + ] }) + ] }); +} +const PauseIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z" }), + "PauseOutlined" +); +const PlayArrowIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M10 8.64 15.27 12 10 15.36V8.64M8 5v14l11-7L8 5z" }), + "PlayArrowOutlined" +); +const endpoint$4 = "billing/subscriptions"; +const columnConfig$6 = [ + { + key: "user_id", + allowsSorting: true, + width: "flex-3 min-w-200", + visibleInMode: "all", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Customer" }), + body: (subscription) => subscription.user && /* @__PURE__ */ jsx( + NameWithAvatar, + { + image: subscription.user.avatar, + label: subscription.user.display_name, + description: subscription.user.email + } + ) + }, + { + key: "status", + width: "w-100 flex-shrink-0", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Status" }), + body: (subscription) => /* @__PURE__ */ jsx( + Chip, + { + size: "xs", + color: subscription.valid ? "positive" : void 0, + radius: "rounded", + className: "w-max", + children: subscription.gateway_status + } + ) + }, + { + key: "product_id", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Plan" }), + body: (subscription) => { + var _a2; + return (_a2 = subscription.product) == null ? void 0 : _a2.name; + } + }, + { + key: "gateway_name", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Gateway" }), + body: (subscription) => /* @__PURE__ */ jsx("span", { className: "capitalize", children: subscription.gateway_name }) + }, + { + key: "renews_at", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Renews at" }), + body: (subscription) => /* @__PURE__ */ jsx(FormattedDate, { date: subscription.renews_at }) + }, + { + key: "ends_at", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Ends at" }), + body: (subscription) => /* @__PURE__ */ jsx(FormattedDate, { date: subscription.ends_at }) + }, + { + key: "created_at", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Created at" }), + body: (subscription) => /* @__PURE__ */ jsx(FormattedDate, { date: subscription.created_at }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + align: "end", + visibleInMode: "all", + width: "w-[168px] flex-shrink-0", + body: (subscription) => { + return /* @__PURE__ */ jsx(SubscriptionActions, { subscription }); + } + } +]; +function SubscriptionsIndexPage() { + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: endpoint$4, + title: /* @__PURE__ */ jsx(Trans, { message: "Subscriptions" }), + columns: columnConfig$6, + filters: SubscriptionIndexPageFilters, + actions: /* @__PURE__ */ jsx(PageActions, {}), + enableSelection: false, + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + queryParams: { with: "product" }, + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: subscriptionsSvg, + title: /* @__PURE__ */ jsx(Trans, { message: "No subscriptions have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching subscriptions" }) + } + ) + } + ); +} +function PageActions() { + return /* @__PURE__ */ jsx(Fragment$1, { children: /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(DataTableAddItemButton, { children: /* @__PURE__ */ jsx(Trans, { message: "Add new subscription" }) }), + /* @__PURE__ */ jsx(CreateSubscriptionDialog, {}) + ] }) }); +} +function SubscriptionActions({ subscription }) { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(IconButton, { size: "md", className: "text-muted", children: /* @__PURE__ */ jsx(EditIcon, {}) }), + /* @__PURE__ */ jsx(UpdateSubscriptionDialog, { subscription }) + ] }), + subscription.cancelled && subscription.on_grace_period ? /* @__PURE__ */ jsx(ResumeSubscriptionButton, { subscription }) : null, + subscription.active ? /* @__PURE__ */ jsx(SuspendSubscriptionButton, { subscription }) : null, + /* @__PURE__ */ jsx(CancelSubscriptionButton, { subscription }) + ] }); +} +function SuspendSubscriptionButton({ subscription }) { + const cancelSubscription = useCancelSubscription(); + const handleSuspendSubscription = () => { + cancelSubscription.mutate( + { subscriptionId: subscription.id }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey(endpoint$4) + }); + } + } + ); + }; + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (confirmed) => { + if (confirmed) { + handleSuspendSubscription(); + } + }, + children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Cancel subscription" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + className: "text-muted", + disabled: cancelSubscription.isPending, + children: /* @__PURE__ */ jsx(PauseIcon, {}) + } + ) }), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Cancel subscription" }), + body: /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to cancel this subscription?" }), + /* @__PURE__ */ jsx("div", { className: "mt-10 text-sm font-semibold", children: /* @__PURE__ */ jsx(Trans, { message: "This will put user on grace period until their next scheduled renewal date. Subscription can be renewed until that date by user or from admin area." }) }) + ] }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Confirm" }) + } + ) + ] + } + ); +} +function ResumeSubscriptionButton({ subscription }) { + const resumeSubscription = useResumeSubscription(); + const handleResumeSubscription = () => { + resumeSubscription.mutate( + { subscriptionId: subscription.id }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey(endpoint$4) + }); + } + } + ); + }; + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (confirmed) => { + if (confirmed) { + handleResumeSubscription(); + } + }, + children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Renew subscription" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + className: "text-muted", + onClick: handleResumeSubscription, + disabled: resumeSubscription.isPending, + children: /* @__PURE__ */ jsx(PlayArrowIcon, {}) + } + ) }), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Resume subscription" }), + body: /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to resume this subscription?" }), + /* @__PURE__ */ jsx("div", { className: "mt-10 text-sm font-semibold", children: /* @__PURE__ */ jsx(Trans, { message: "This will put user on their original plan and billing cycle." }) }) + ] }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Confirm" }) + } + ) + ] + } + ); +} +function CancelSubscriptionButton({ subscription }) { + const cancelSubscription = useCancelSubscription(); + const handleDeleteSubscription = () => { + cancelSubscription.mutate( + { subscriptionId: subscription.id, delete: true }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey(endpoint$4) + }); + } + } + ); + }; + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (confirmed) => { + if (confirmed) { + handleDeleteSubscription(); + } + }, + children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Delete subscription" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + className: "text-muted", + disabled: cancelSubscription.isPending, + children: /* @__PURE__ */ jsx(CloseIcon, {}) + } + ) }), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isDanger: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Delete subscription" }), + body: /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete this subscription?" }), + /* @__PURE__ */ jsx("div", { className: "mt-10 text-sm font-semibold", children: /* @__PURE__ */ jsx(Trans, { message: "This will permanently delete the subscription and immediately cancel it on billing gateway. Subscription will not be renewable anymore." }) }) + ] }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Confirm" }) + } + ) + ] + } + ); +} +const SyncIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z" }), + "SyncOutlined" +); +function useSyncProducts() { + const { trans } = useTrans(); + return useMutation({ + mutationFn: () => syncPlans(), + onSuccess: () => { + toast(trans(message("Plans synced"))); + }, + onError: (err) => showHttpErrorToast(err, message("Could not sync plans")) + }); +} +function syncPlans() { + return apiClient.post("billing/products/sync").then((r) => r.data); +} +const endpoint$3 = (id) => `billing/products/${id}`; +function useDeleteProduct() { + const { trans } = useTrans(); + return useMutation({ + mutationFn: (payload) => updateProduct$1(payload), + onSuccess: () => { + toast(trans(message("Plan deleted"))); + queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("billing/products") + }); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function updateProduct$1({ productId }) { + return apiClient.delete(endpoint$3(productId)).then((r) => r.data); +} +const PlansIndexPageFilters = [ + { + key: "subscriptions", + label: message("Subscriptions"), + description: message("Whether plan has any active subscriptions"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "01", + options: [ + { + key: "01", + label: message("Has active subscriptions"), + value: { value: "*", operator: FilterOperator.has } + }, + { + key: "02", + label: message("Does not have active subscriptions"), + value: { value: "*", operator: FilterOperator.doesntHave } + } + ] + } + }, + createdAtFilter({ + description: message("Date plan was created") + }), + updatedAtFilter({ + description: message("Date plan was last updated") + }) +]; +const columnConfig$5 = [ + { + key: "name", + allowsSorting: true, + visibleInMode: "all", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Name" }), + body: (product) => { + const price = product.prices[0]; + return /* @__PURE__ */ jsx( + NameWithAvatar, + { + label: product.name, + description: product.free ? /* @__PURE__ */ jsx(Trans, { message: "Free" }) : /* @__PURE__ */ jsx(FormattedPrice, { price }) + } + ); + } + }, + { + key: "created_at", + allowsSorting: true, + maxWidth: "max-w-100", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Created" }), + body: (product) => /* @__PURE__ */ jsx(FormattedDate, { date: product.created_at }) + }, + { + key: "updated_at", + allowsSorting: true, + maxWidth: "max-w-100", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (product) => /* @__PURE__ */ jsx(FormattedDate, { date: product.updated_at }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + visibleInMode: "all", + hideHeader: true, + align: "end", + maxWidth: "max-w-84", + body: (product) => { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + className: "text-muted", + elementType: Link, + to: `/admin/plans/${product.id}/edit`, + children: /* @__PURE__ */ jsx(EditIcon, {}) + } + ), + /* @__PURE__ */ jsx(DeleteProductButton, { product }) + ] }); + } + } +]; +function PlansIndexPage() { + const navigate = useNavigate$1(); + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "billing/products", + title: /* @__PURE__ */ jsx(Trans, { message: "Subscription plans" }), + columns: columnConfig$5, + actions: /* @__PURE__ */ jsx(Actions$a, {}), + enableSelection: false, + filters: PlansIndexPageFilters, + onRowAction: (item) => { + navigate(`/admin/plans/${item.id}/edit`); + }, + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: softwareEngineerSvg, + title: /* @__PURE__ */ jsx(Trans, { message: "No plans have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching plans" }) + } + ) + } + ); +} +function DeleteProductButton({ product }) { + const deleteProduct = useDeleteProduct(); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (confirmed) => { + if (confirmed) { + deleteProduct.mutate({ productId: product.id }); + } + }, + children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Delete plan" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + className: "text-muted", + disabled: deleteProduct.isPending, + children: /* @__PURE__ */ jsx(DeleteIcon, {}) + } + ) }), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Delete plan" }), + body: /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete this plan?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ) + ] + } + ); +} +function Actions$a() { + const syncPlans2 = useSyncProducts(); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Sync plans with Stripe & PayPal" }), children: /* @__PURE__ */ jsx( + IconButton, + { + color: "primary", + variant: "outline", + size: "sm", + disabled: syncPlans2.isPending, + onClick: () => { + syncPlans2.mutate(); + }, + children: /* @__PURE__ */ jsx(SyncIcon, {}) + } + ) }), + /* @__PURE__ */ jsx(DataTableAddItemButton, { elementType: Link, to: "/admin/plans/new", children: /* @__PURE__ */ jsx(Trans, { message: "Add new plan" }) }) + ] }); +} +const Endpoint$3 = (id) => `billing/products/${id}`; +function useProduct() { + const { productId } = useParams(); + return useQuery({ + queryKey: [Endpoint$3(productId)], + queryFn: () => fetchProduct(productId) + }); +} +function fetchProduct(productId) { + return apiClient.get(Endpoint$3(productId)).then((response) => response.data); +} +const BillingPeriodPresets = [ + { + key: "day1", + label: message("Daily"), + interval: "day", + interval_count: 1 + }, + { + key: "week1", + label: message("Weekly"), + interval: "week", + interval_count: 1 + }, + { + key: "month1", + label: message("Monthly"), + interval: "month", + interval_count: 1 + }, + { + key: "month3", + label: message("Every 3 months"), + interval: "month", + interval_count: 3 + }, + { + key: "month6", + label: message("Every 6 months"), + interval: "month", + interval_count: 6 + }, + { + key: "year1", + label: message("Yearly"), + interval: "year", + interval_count: 1 + }, + { + key: "custom", + label: message("Custom"), + interval: null, + interval_count: null + } +]; +function PriceForm({ index, onRemovePrice }) { + const { trans } = useTrans(); + const query = useValueLists(["currencies"]); + const currencies = useMemo(() => { + var _a2; + return ((_a2 = query.data) == null ? void 0 : _a2.currencies) ? Object.values(query.data.currencies) : []; + }, [query.data]); + const { watch, getValues } = useFormContext(); + const isNewProduct = !watch("id"); + const isNewPrice = watch(`prices.${index}.id`) == null; + const subscriberCount = watch(`prices.${index}.subscriptions_count`) || 0; + const [billingPeriodPreset, setBillingPeriodPreset] = useState(() => { + const interval = getValues(`prices.${index}.interval`); + const intervalCount = getValues(`prices.${index}.interval_count`); + const preset = BillingPeriodPresets.find( + (p) => p.key === `${interval}${intervalCount}` + ); + return preset ? preset.key : "custom"; + }); + const allowPriceChanges = isNewProduct || isNewPrice || !subscriberCount; + return /* @__PURE__ */ jsxs(Fragment, { children: [ + !allowPriceChanges && /* @__PURE__ */ jsx("p", { className: "text-muted text-sm max-w-500 mb-20", children: /* @__PURE__ */ jsx( + Trans, + { + message: "This price can't modified or deleted, because it has [one 1 subscriber|other :count subscribers]. You can instead add a new price.", + values: { count: subscriberCount } + } + ) }), + /* @__PURE__ */ jsx( + FormTextField, + { + required: true, + disabled: !allowPriceChanges, + label: /* @__PURE__ */ jsx(Trans, { message: "Amount" }), + type: "number", + min: 0.1, + step: 0.01, + name: `prices.${index}.amount`, + className: "mb-20" + } + ), + /* @__PURE__ */ jsx( + FormSelect, + { + required: true, + disabled: !allowPriceChanges, + label: /* @__PURE__ */ jsx(Trans, { message: "Currency" }), + name: `prices.${index}.currency`, + items: currencies, + showSearchField: true, + searchPlaceholder: trans(message("Search currencies")), + selectionMode: "single", + className: "mb-20", + children: (item) => /* @__PURE__ */ jsx( + Item, + { + value: item.code, + children: `${item.code}: ${item.name}` + }, + item.code + ) + } + ), + /* @__PURE__ */ jsx( + BillingPeriodSelect, + { + disabled: !allowPriceChanges, + index, + value: billingPeriodPreset, + onValueChange: setBillingPeriodPreset + } + ), + billingPeriodPreset === "custom" && /* @__PURE__ */ jsx(CustomBillingPeriodField, { disabled: !allowPriceChanges, index }), + /* @__PURE__ */ jsx("div", { className: "text-right", children: /* @__PURE__ */ jsx( + Button, + { + size: "xs", + variant: "outline", + color: "danger", + disabled: !allowPriceChanges, + onClick: () => { + onRemovePrice(); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Delete price" }) + } + ) }) + ] }); +} +function BillingPeriodSelect({ + index, + value, + onValueChange, + disabled +}) { + const { setValue: setFormValue } = useFormContext(); + return /* @__PURE__ */ jsx( + SelectForwardRef, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Billing period" }), + disabled, + className: "mb-20", + selectionMode: "single", + selectedValue: value, + onSelectionChange: (value2) => { + onValueChange(value2); + if (value2 === "custom") + ; + else { + const preset = BillingPeriodPresets.find((p) => p.key === value2); + if (preset) { + setFormValue( + `prices.${index}.interval`, + preset.interval + ); + setFormValue( + `prices.${index}.interval_count`, + preset.interval_count + ); + } + } + }, + children: BillingPeriodPresets.map((preset) => /* @__PURE__ */ jsx(Item, { value: preset.key, children: /* @__PURE__ */ jsx(Trans, { ...preset.label }) }, preset.key)) + } + ); +} +function CustomBillingPeriodField({ + index, + disabled +}) { + const { watch } = useFormContext(); + const interval = watch(`prices.${index}.interval`); + let maxIntervalCount; + if (interval === "day") { + maxIntervalCount = 365; + } else if (interval === "week") { + maxIntervalCount = 52; + } else { + maxIntervalCount = 12; + } + return /* @__PURE__ */ jsxs("div", { className: "flex border rounded w-min", children: [ + /* @__PURE__ */ jsx("div", { className: "px-18 flex items-center text-sm", children: /* @__PURE__ */ jsx(Trans, { message: "Every" }) }), + /* @__PURE__ */ jsx( + FormTextField, + { + inputShadow: "shadow-none", + inputBorder: "border-none", + className: "border-l border-r w-80", + name: `prices.${index}.interval_count`, + type: "number", + min: 1, + max: maxIntervalCount, + disabled, + required: true + } + ), + /* @__PURE__ */ jsxs( + FormSelect, + { + inputShadow: "shadow-none", + inputBorder: "border-none", + name: `prices.${index}.interval`, + selectionMode: "single", + disabled, + children: [ + /* @__PURE__ */ jsx(Item, { value: "day", children: /* @__PURE__ */ jsx(Trans, { message: "Days" }) }), + /* @__PURE__ */ jsx(Item, { value: "week", children: /* @__PURE__ */ jsx(Trans, { message: "Weeks" }) }), + /* @__PURE__ */ jsx(Item, { value: "month", children: /* @__PURE__ */ jsx(Trans, { message: "Months" }) }) + ] + } + ) + ] }); +} +function CrupdatePlanForm() { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Name" }), + className: "mb-20", + required: true, + autoFocus: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "description", + label: /* @__PURE__ */ jsx(Trans, { message: "Description" }), + className: "mb-20", + inputElementType: "textarea", + rows: 4 + } + ), + /* @__PURE__ */ jsxs( + FormSelect, + { + name: "position", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Position in pricing table" }), + className: "mb-20", + children: [ + /* @__PURE__ */ jsx(Item, { value: 0, children: /* @__PURE__ */ jsx(Trans, { message: "First" }) }), + /* @__PURE__ */ jsx(Item, { value: 1, children: /* @__PURE__ */ jsx(Trans, { message: "Second" }) }), + /* @__PURE__ */ jsx(Item, { value: 2, children: /* @__PURE__ */ jsx(Trans, { message: "Third" }) }), + /* @__PURE__ */ jsx(Item, { value: 3, children: /* @__PURE__ */ jsx(Trans, { message: "Fourth" }) }), + /* @__PURE__ */ jsx(Item, { value: 4, children: /* @__PURE__ */ jsx(Trans, { message: "Fifth" }) }) + ] + } + ), + /* @__PURE__ */ jsx( + FormFileSizeField, + { + className: "mb-30", + name: "available_space", + label: /* @__PURE__ */ jsx(Trans, { message: "Allowed storage space" }), + description: /* @__PURE__ */ jsx( + Trans, + { + values: { + a: (parts) => /* @__PURE__ */ jsx( + Link, + { + className: LinkStyle, + target: "_blank", + to: "/admin/settings/uploading", + children: parts + } + ) + }, + message: "Total storage space all user uploads are allowed to take up." + } + ) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "recommended", + className: "mb-20", + description: /* @__PURE__ */ jsx(Trans, { message: "Plan will be displayed more prominently on pricing page." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Recommend" }) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "hidden", + className: "mb-20", + description: /* @__PURE__ */ jsx(Trans, { message: "Plan will not be shown on pricing or upgrade pages." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Hidden" }) + } + ), + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "free", + className: "mb-20", + description: /* @__PURE__ */ jsx(Trans, { message: "Will be assigned to all users, if they are not subscribed already." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Free" }) + } + ), + /* @__PURE__ */ jsx(Header$1, { children: /* @__PURE__ */ jsx(Trans, { message: "Feature list" }) }), + /* @__PURE__ */ jsx(FeatureListForm, {}), + /* @__PURE__ */ jsx(PricingListForm, {}), + /* @__PURE__ */ jsx(Header$1, { children: /* @__PURE__ */ jsx(Trans, { message: "Permissions" }) }), + /* @__PURE__ */ jsx(FormPermissionSelector, { name: "permissions" }) + ] }); +} +function Header$1({ children }) { + return /* @__PURE__ */ jsx("h2", { className: "mt-40 mb-20 text-base font-semibold", children }); +} +function FeatureListForm() { + const { fields, append, remove } = useFieldArray({ + name: "feature_list" + }); + return /* @__PURE__ */ jsxs("div", { children: [ + fields.map((field, index) => { + return /* @__PURE__ */ jsxs("div", { className: "flex gap-10 mb-10", children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: `feature_list.${index}.value`, + size: "sm", + className: "flex-auto" + } + ), + /* @__PURE__ */ jsx( + IconButton, + { + size: "sm", + color: "primary", + className: "flex-shrink-0", + onClick: () => { + remove(index); + }, + children: /* @__PURE__ */ jsx(CloseIcon, {}) + } + ) + ] }, field.id); + }), + /* @__PURE__ */ jsx( + Button, + { + variant: "text", + color: "primary", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + size: "xs", + onClick: () => { + append({ value: "" }); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Add another line" }) + } + ) + ] }); +} +function PricingListForm() { + var _a2; + const { + watch, + formState: { errors } + } = useFormContext(); + const { fields, append, remove } = useFieldArray({ + name: "prices", + keyName: "key" + }); + if (watch("free")) { + return null; + } + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(Header$1, { children: /* @__PURE__ */ jsx(Trans, { message: "Pricing" }) }), + ((_a2 = errors.prices) == null ? void 0 : _a2.message) && /* @__PURE__ */ jsx("div", { className: "text-sm text-danger mb-20", children: errors.prices.message }), + /* @__PURE__ */ jsx(Accordion, { variant: "outline", className: "mb-10", children: fields.map((field, index) => /* @__PURE__ */ jsx( + AccordionItem, + { + label: /* @__PURE__ */ jsx(FormattedPrice, { price: field }), + children: /* @__PURE__ */ jsx( + PriceForm, + { + index, + onRemovePrice: () => { + remove(index); + } + } + ) + }, + field.key + )) }), + /* @__PURE__ */ jsx( + Button, + { + variant: "text", + color: "primary", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + size: "xs", + onClick: () => { + append({ + currency: "USD", + amount: 1, + interval_count: 1, + interval: "month" + }); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Add another price" }) + } + ) + ] }); +} +const Endpoint$2 = (id) => `billing/products/${id}`; +function useUpdateProduct(form) { + const { trans } = useTrans(); + const navigate = useNavigate$1(); + return useMutation({ + mutationFn: (payload) => updateProduct(payload), + onSuccess: (response) => { + toast(trans(message("Plan updated"))); + queryClient.invalidateQueries({ + queryKey: [Endpoint$2(response.product.id)] + }); + queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("billing/products") + }); + navigate("/admin/plans"); + }, + onError: (err) => onFormQueryError(err, form) + }); +} +function updateProduct({ + id, + ...payload +}) { + const backendPayload = { + ...payload, + feature_list: payload.feature_list.map((feature) => feature.value) + }; + return apiClient.put(Endpoint$2(id), backendPayload).then((r) => r.data); +} +function EditPlanPage() { + const query = useProduct(); + if (query.status !== "success") { + return /* @__PURE__ */ jsx(FullPageLoader, {}); + } + return /* @__PURE__ */ jsx(PageContent$4, { product: query.data.product }); +} +function PageContent$4({ product }) { + const form = useForm({ + defaultValues: { + ...product, + feature_list: product.feature_list.map((f) => ({ value: f })) + } + }); + const updateProduct2 = useUpdateProduct(form); + return /* @__PURE__ */ jsx( + CrupdateResourceLayout, + { + form, + onSubmit: (values) => { + updateProduct2.mutate(values); + }, + title: /* @__PURE__ */ jsx(Trans, { message: "Edit “:name“ plan", values: { name: product.name } }), + isLoading: updateProduct2.isPending, + children: /* @__PURE__ */ jsx(CrupdatePlanForm, {}) + } + ); +} +const endpoint$2 = "billing/products"; +function useCreateProduct(form) { + const { trans } = useTrans(); + const navigate = useNavigate$1(); + return useMutation({ + mutationFn: (payload) => createProduct(payload), + onSuccess: () => { + toast(trans(message("Plan created"))); + queryClient.invalidateQueries({ queryKey: [endpoint$2] }); + queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("billing/products") + }); + navigate("/admin/plans"); + }, + onError: (err) => onFormQueryError(err, form) + }); +} +function createProduct(payload) { + const backendPayload = { + ...payload, + feature_list: payload.feature_list.map((feature) => feature.value) + }; + return apiClient.post(endpoint$2, backendPayload).then((r) => r.data); +} +function CreatePlanPage() { + const form = useForm({ + defaultValues: { + free: false, + recommended: false + } + }); + const createProduct2 = useCreateProduct(form); + return /* @__PURE__ */ jsx( + CrupdateResourceLayout, + { + form, + onSubmit: (values) => { + createProduct2.mutate(values); + }, + title: /* @__PURE__ */ jsx(Trans, { message: "Create new plan" }), + isLoading: createProduct2.isPending, + children: /* @__PURE__ */ jsx(CrupdatePlanForm, {}) + } + ); +} +function GdprSettings() { + return /* @__PURE__ */ jsxs( + SettingsPanel, + { + title: /* @__PURE__ */ jsx(Trans, { message: "GDPR" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Configure settings related to EU General Data Protection Regulation." }), + children: [ + /* @__PURE__ */ jsx(CookieNoticeSection, {}), + /* @__PURE__ */ jsx(SettingsSeparator, {}), + /* @__PURE__ */ jsx(RegistrationPoliciesSection, {}) + ] + } + ); +} +function CookieNoticeSection() { + const { watch } = useFormContext(); + const noticeEnabled = watch("client.cookie_notice.enable"); + return /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx( + FormSwitch, + { + name: "client.cookie_notice.enable", + className: "mb-20", + description: /* @__PURE__ */ jsx(Trans, { message: "Whether cookie notice should be shown automatically to users from EU until it is accepted." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Enable cookie notice" }) + } + ), + noticeEnabled && /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs("div", { className: "mb-20 border-b pb-6", children: [ + /* @__PURE__ */ jsx("div", { className: "mb-20 border-b pb-10 text-sm font-medium", children: /* @__PURE__ */ jsx(Trans, { message: "Information button" }) }), + /* @__PURE__ */ jsx( + MenuItemForm, + { + hideRoleAndPermissionFields: true, + formPathPrefix: "client.cookie_notice.button" + } + ) + ] }), + /* @__PURE__ */ jsxs( + FormSelect, + { + name: "client.cookie_notice.position", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Cookie notice position" }), + className: "mb-20", + children: [ + /* @__PURE__ */ jsx(Item, { value: "top", children: /* @__PURE__ */ jsx(Trans, { message: "Top" }) }), + /* @__PURE__ */ jsx(Item, { value: "bottom", children: /* @__PURE__ */ jsx(Trans, { message: "Bottom" }) }) + ] + } + ) + ] }) + ] }); +} +function RegistrationPoliciesSection() { + const { fields, append, remove } = useFieldArray({ + name: "client.registration.policies" + }); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx("div", { className: "mb-6 text-sm", children: /* @__PURE__ */ jsx(Trans, { message: "Registration policies" }) }), + /* @__PURE__ */ jsx("div", { className: "text-xs text-muted", children: /* @__PURE__ */ jsx(Trans, { message: "Create policies that will be shown on registration page. User will be required to accept them by toggling a checkbox." }) }), + /* @__PURE__ */ jsx(Accordion, { className: "mt-16", variant: "outline", children: fields.map((field, index) => /* @__PURE__ */ jsx( + AccordionItem, + { + label: field.label, + chevronPosition: "left", + endAppend: /* @__PURE__ */ jsx( + IconButton, + { + variant: "text", + color: "danger", + size: "sm", + onClick: () => { + remove(index); + }, + children: /* @__PURE__ */ jsx(CloseIcon, {}) + } + ), + children: /* @__PURE__ */ jsx( + MenuItemForm, + { + hideRoleAndPermissionFields: true, + formPathPrefix: `client.register_policies.${index}` + } + ) + }, + field.id + )) }), + /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (value) => { + if (value) { + append(value); + } + }, + children: [ + /* @__PURE__ */ jsx( + Button, + { + className: "mt-12", + variant: "link", + color: "primary", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + size: "xs", + children: /* @__PURE__ */ jsx(Trans, { message: "Add another policy" }) + } + ), + /* @__PURE__ */ jsx(AddMenuItemDialog, { title: /* @__PURE__ */ jsx(Trans, { message: "Add policy" }) }) + ] + } + ) + ] }); +} +const InfoDialogTriggerIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" }), + "InfoDialogTrigger" +); +function InfoDialogTrigger({ + title, + body, + dialogSize = "sm", + className +}) { + return /* @__PURE__ */ jsxs(DialogTrigger, { type: "popover", triggerOnHover: true, children: [ + /* @__PURE__ */ jsx( + IconButton, + { + className: clsx("ml-4 text-muted opacity-70", className), + iconSize: "xs", + size: "2xs", + children: /* @__PURE__ */ jsx(InfoDialogTriggerIcon, { viewBox: "0 0 16 16" }) + } + ), + /* @__PURE__ */ jsxs(Dialog, { size: dialogSize, children: [ + title && /* @__PURE__ */ jsx(DialogHeader, { padding: "px-18 pt-12", size: "md", hideDismissButton: true, children: title }), + /* @__PURE__ */ jsx(DialogBody, { children: body }) + ] }) + ] }); +} +const HomeIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "m12 5.69 5 4.5V18h-2v-6H9v6H7v-7.81l5-4.5M12 3 2 12h3v8h6v-6h2v6h6v-8h3L12 3z" }), + "HomeOutlined" +); +const ChannelsDatatableColumns = [ + { + key: "name", + allowsSorting: true, + width: "flex-3", + visibleInMode: "all", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Name" }), + body: (channel) => { + return /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx("div", { className: "overflow-hidden overflow-ellipsis whitespace-nowrap font-medium", children: /* @__PURE__ */ jsx(ChannelName, { channel }) }), + channel.config.adminDescription && /* @__PURE__ */ jsx("p", { className: "max-w-680 whitespace-normal text-xs text-muted", children: channel.config.adminDescription }) + ] }); + } + }, + { + key: "content", + allowsSorting: false, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Content" }), + body: (channel) => /* @__PURE__ */ jsx(ContentType, { channel }) + }, + { + key: "content_type", + allowsSorting: false, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Content type" }), + body: (channel) => /* @__PURE__ */ jsx("span", { className: "capitalize", children: channel.config.contentModel ? /* @__PURE__ */ jsx(Trans, { message: channel.config.contentModel }) : void 0 }) + }, + { + key: "internal", + allowsSorting: true, + maxWidth: "max-w-100", + hideHeader: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Internal" }), + body: (channel) => /* @__PURE__ */ jsx(InternalColumn, { channel }) + }, + { + key: "updated_at", + allowsSorting: true, + maxWidth: "max-w-100", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (channel) => channel.updated_at ? /* @__PURE__ */ jsx(FormattedDate, { date: channel.updated_at }) : "" + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + visibleInMode: "all", + align: "end", + width: "w-42 flex-shrink-0", + body: (channel) => /* @__PURE__ */ jsx(Link, { to: `${channel.id}/edit`, className: "text-muted", children: /* @__PURE__ */ jsx(IconButton, { size: "md", children: /* @__PURE__ */ jsx(EditIcon, {}) }) }) + } +]; +function ContentType({ channel }) { + switch (channel.config.contentType) { + case "listAll": + return /* @__PURE__ */ jsx(Trans, { message: "List all" }); + case "manual": + return /* @__PURE__ */ jsx(Trans, { message: "Managed manually" }); + case "autoUpdate": + return /* @__PURE__ */ jsx(Trans, { message: "Updated automatically" }); + } +} +function ChannelName({ channel }) { + if (channel.config.restriction && channel.config.restrictionModelId === "urlParam") { + return channel.name; + } + return /* @__PURE__ */ jsx( + "a", + { + className: "outline-none hover:underline focus-visible:underline", + href: `channel/${channel.slug}`, + target: "_blank", + rel: "noreferrer", + children: channel.name + } + ); +} +function InternalColumn({ channel }) { + const { homepage } = useSettings(); + const internalLabel = channel.internal ? /* @__PURE__ */ jsx( + Tooltip, + { + label: /* @__PURE__ */ jsx(Trans, { message: "This channel is required for some site functionality to work properly and can't be deleted." }), + children: /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Chip, { className: "w-max", size: "xs", radius: "rounded-panel", children: /* @__PURE__ */ jsx(Trans, { message: "Internal" }) }) }) + } + ) : ""; + const isHomepage = (homepage == null ? void 0 : homepage.type) === "channels" && `${homepage.value}` === `${channel.id}`; + return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-6", children: [ + internalLabel, + isHomepage ? /* @__PURE__ */ jsx(HomeIcon, { className: "text-muted", size: "sm" }) : null + ] }); +} +function useApplyChannelPreset() { + const { trans } = useTrans(); + return useMutation({ + mutationFn: (payload) => resetChannels(payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("channel") + }); + toast(trans(message("Channel preset applied"))); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function resetChannels(payload) { + return apiClient.post("channel/apply-preset", payload).then((r) => r.data); +} +function ChannelsDocsLink({ className, hash }) { + const { admin } = useContext(SiteConfigContext); + if (!(admin == null ? void 0 : admin.channelsDocsLink)) + return null; + const link = hash ? `${admin.channelsDocsLink}#${hash}` : admin.channelsDocsLink; + return /* @__PURE__ */ jsx(LearnMoreLink, { link, className }); +} +function ChannelsDatatablePage() { + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "channel", + title: /* @__PURE__ */ jsx(Trans, { message: "Channels" }), + headerContent: /* @__PURE__ */ jsx(InfoTrigger$2, {}), + headerItemsAlign: "items-center", + queryParams: { type: "channel" }, + columns: ChannelsDatatableColumns, + actions: /* @__PURE__ */ jsx(Actions$9, {}), + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + cellHeight: "h-52", + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: playlist, + title: /* @__PURE__ */ jsx(Trans, { message: "No channels have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching channels" }) + } + ) + } + ); +} +function InfoTrigger$2() { + return /* @__PURE__ */ jsx( + InfoDialogTrigger, + { + body: /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Channels are used to create pages that show various content on the site." }), + /* @__PURE__ */ jsx(ChannelsDocsLink, { className: "mt-14" }) + ] }) + } + ); +} +function Actions$9() { + var _a2, _b; + const { query } = useDataTable(); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs( + MenuTrigger, + { + onItemSelected: (preset) => openDialog(ApplyPresetDialog, { preset }), + children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "primary", + size: "sm", + endIcon: /* @__PURE__ */ jsx(KeyboardArrowDownIcon, {}), + disabled: !((_a2 = query.data) == null ? void 0 : _a2.presets.length), + children: /* @__PURE__ */ jsx(Trans, { message: "Apply preset" }) + } + ), + /* @__PURE__ */ jsx(Menu, { children: (_b = query.data) == null ? void 0 : _b.presets.map((preset) => /* @__PURE__ */ jsx( + Item, + { + value: preset.preset, + description: /* @__PURE__ */ jsx(Trans, { message: preset.description }), + children: /* @__PURE__ */ jsx(Trans, { message: preset.name }) + }, + preset.preset + )) }) + ] + } + ), + /* @__PURE__ */ jsx(DataTableAddItemButton, { elementType: Link, to: "new", children: /* @__PURE__ */ jsx(Trans, { message: "Add new channel" }) }) + ] }); +} +function ApplyPresetDialog({ preset }) { + const { close } = useDialogContext(); + const resetChannels2 = useApplyChannelPreset(); + return /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isLoading: resetChannels2.isPending, + onConfirm: () => { + resetChannels2.mutate({ preset }, { onSuccess: () => close() }); + }, + isDanger: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Apply preset" }), + body: /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to apply this channel preset? This will delete all current channels and leave only channels from the selected preset." }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Apply" }) + } + ); +} +const Endpoint$1 = (id) => `channel/${id}`; +function useUpdateChannel(form) { + const { trans } = useTrans(); + const navigate = useNavigate$1(); + return useMutation({ + mutationFn: (payload) => updateChannel(payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("channel") + }); + toast(trans(message("Channel updated"))); + navigate("/admin/channels"); + }, + onError: (err) => onFormQueryError(err, form) + }); +} +function updateChannel({ + id, + ...payload +}) { + return apiClient.put(Endpoint$1(id), payload).then((r) => r.data); +} +function EditChannelPageLayout({ children }) { + const query = useChannel(void 0, "editChannelPage"); + if (query.data) { + return /* @__PURE__ */ jsx(PageContent$3, { channel: query.data.channel, children }); + } + return /* @__PURE__ */ jsx(PageStatus, { query, loaderIsScreen: false }); +} +function PageContent$3({ channel, children }) { + const form = useForm({ + // @ts-ignore + defaultValues: { + ...channel + } + }); + const updateChannel2 = useUpdateChannel(form); + return /* @__PURE__ */ jsx( + CrupdateResourceLayout, + { + form, + onSubmit: (values) => { + updateChannel2.mutate(values); + }, + title: /* @__PURE__ */ jsx(Trans, { message: "Edit “:name“ channel", values: { name: channel.name } }), + isLoading: updateChannel2.isPending, + children + } + ); +} +const DescriptionIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z" }), + "DescriptionOutlined" +); +function SlugEditor({ + host, + value: initialValue = "", + placeholder, + onChange, + className, + inputRef, + onInputBlur, + showLinkIcon = true, + pattern, + minLength, + maxLength, + hideButton, + ...props +}) { + const { base_url } = useSettings(); + const prefix = props.prefix ? `/${props.prefix}` : ""; + const suffix = props.suffix ? `/${props.suffix}` : ""; + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(initialValue); + host = host || base_url; + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + const handleSubmit = () => { + if (!isEditing) { + setIsEditing(true); + } else { + setIsEditing(false); + if (value) { + onChange == null ? void 0 : onChange(value); + } + } + }; + let preview = ""; + if (value) { + preview = value; + } else if (placeholder) { + preview = slugifyString(placeholder); + } + return ( + // can't use
here as component might be used inside another form + /* @__PURE__ */ jsxs("div", { className: clsx("flex items-center", className), children: [ + showLinkIcon && /* @__PURE__ */ jsx(LinkIcon, { className: "icon-md text-muted" }), + /* @__PURE__ */ jsxs("div", { className: "text-primary ml-6 mr-14", children: [ + host, + prefix, + !isEditing && preview && /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx("span", { children: "/" }), + /* @__PURE__ */ jsx("span", { className: "font-medium", children: preview }) + ] }), + !isEditing ? suffix : null + ] }), + isEditing && /* @__PURE__ */ jsx( + TextField, + { + pattern, + minLength, + maxLength, + onKeyDown: (e) => { + if (e.key === "Enter") { + handleSubmit(); + } + }, + ref: inputRef, + "aria-label": "slug", + autoFocus: true, + className: "mr-14", + size: "2xs", + value, + onBlur: onInputBlur, + onChange: (e) => { + setValue(e.target.value); + } + } + ), + !hideButton && /* @__PURE__ */ jsx( + Button, + { + type: "button", + color: "chip", + variant: "outline", + size: "2xs", + onClick: () => { + handleSubmit(); + }, + children: isEditing ? /* @__PURE__ */ jsx(Trans, { message: "Save" }) : /* @__PURE__ */ jsx(Trans, { message: "Edit" }) + } + ) + ] }) + ); +} +function ChannelNameField({ className, autoFocus }) { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Title" }), + required: true, + autoFocus, + className: clsx("mb-10", className) + } + ), + /* @__PURE__ */ jsx(FormSlugField, {}) + ] }); +} +function FormSlugField() { + const { watch, setValue } = useFormContext(); + const value = watch("slug"); + const name = watch("name"); + const disableSlugEditing = watch("config.lockSlug"); + const restriction = watch("config.restriction"); + const restrictionId = watch("config.restrictionModelId"); + const { trans } = useTrans(); + return /* @__PURE__ */ jsx( + SlugEditor, + { + hideButton: disableSlugEditing, + placeholder: name, + suffix: restriction && restrictionId === "urlParam" ? trans(message(":restriction_name", { values: { restriction } })) : void 0, + className: "text-sm", + pattern: "[A-Za-z0-9_-]+", + minLength: 3, + maxLength: 20, + value, + onChange: (newSlug) => { + setValue("slug", newSlug); + } + } + ); +} +function ContentTypeField({ config, className }) { + const { setValue } = useFormContext(); + return /* @__PURE__ */ jsxs( + FormSelect, + { + className, + selectionMode: "single", + name: "config.contentType", + label: /* @__PURE__ */ jsx(Trans, { message: "Content" }), + onSelectionChange: (newValue) => { + var _a2; + let model = Object.entries(config.models)[0]; + if (newValue === "autoUpdate") { + const newModel = Object.entries(config.models).find( + ([, modelConfig2]) => { + var _a3; + return (_a3 = modelConfig2.autoUpdateMethods) == null ? void 0 : _a3.length; + } + ); + if (newModel) { + model = newModel; + } + } + const [modelName, modelConfig] = model; + setValue("config.contentModel", modelName); + setValue("config.restrictionModelId", void 0); + setValue( + "config.autoUpdateMethod", + newValue === "autoUpdate" ? (_a2 = modelConfig.autoUpdateMethods) == null ? void 0 : _a2[0] : "" + ); + setValue("config.contentOrder", modelConfig.sortMethods[0]); + setValue("config.restriction", null); + }, + children: [ + /* @__PURE__ */ jsx(Item, { value: "listAll", children: /* @__PURE__ */ jsx(Trans, { message: "List all content of specified type" }) }), + /* @__PURE__ */ jsx(Item, { value: "manual", children: /* @__PURE__ */ jsx(Trans, { message: "Manage content manually" }) }), + /* @__PURE__ */ jsx(Item, { value: "autoUpdate", children: /* @__PURE__ */ jsx(Trans, { message: "Automatically update content with specified method" }) }) + ] + } + ); +} +function ContentAutoUpdateField({ children, config, className }) { + var _a2; + const { watch, setValue } = useFormContext(); + const modelConfig = config.models[watch("config.contentModel")]; + const selectedMethodConfig = config.autoUpdateMethods[watch("config.autoUpdateMethod")]; + if (watch("config.contentType") !== "autoUpdate" || !((_a2 = modelConfig.autoUpdateMethods) == null ? void 0 : _a2.length)) { + return null; + } + return /* @__PURE__ */ jsxs("div", { className: clsx("items-end gap-14 md:flex", className), children: [ + /* @__PURE__ */ jsx( + FormSelect, + { + required: true, + className: "flex-auto", + selectionMode: "single", + name: "config.autoUpdateMethod", + onSelectionChange: (value) => { + if (config.autoUpdateMethods[value].provider) { + setValue( + "config.autoUpdateProvider", + config.autoUpdateMethods[value].provider + ); + } + }, + label: /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Auto update method" }), + /* @__PURE__ */ jsx( + InfoDialogTrigger, + { + body: /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx("div", { className: "mb-20", children: /* @__PURE__ */ jsx(Trans, { message: "This option will automatically update channel content every 24 hours using the specified method." }) }), + /* @__PURE__ */ jsx(ChannelsDocsLink, { hash: "automatically-update-content-with-specified-method" }) + ] }) + } + ) + ] }), + children: modelConfig.autoUpdateMethods.map((method) => /* @__PURE__ */ jsx(Item, { value: method, children: /* @__PURE__ */ jsx(Trans, { ...config.autoUpdateMethods[method].label }) }, method)) + } + ), + (selectedMethodConfig == null ? void 0 : selectedMethodConfig.value) ? /* @__PURE__ */ jsx( + FormTextField, + { + name: "config.autoUpdateValue", + required: true, + className: "flex-auto", + label: /* @__PURE__ */ jsx(Trans, { ...selectedMethodConfig == null ? void 0 : selectedMethodConfig.value.label }), + type: selectedMethodConfig == null ? void 0 : selectedMethodConfig.value.inputType + } + ) : null, + children + ] }); +} +function ChannelAutoUpdateField({ className }) { + const { tmdb_is_setup } = useSettings(); + const { watch } = useFormContext(); + const methodConfig = channelContentConfig.autoUpdateMethods[watch("config.autoUpdateMethod")]; + return /* @__PURE__ */ jsx(ContentAutoUpdateField, { config: channelContentConfig, className, children: !(methodConfig == null ? void 0 : methodConfig.provider) && tmdb_is_setup && /* @__PURE__ */ jsxs( + FormSelect, + { + selectionMode: "single", + className: "mt-24 flex-auto md:mt-0", + name: "config.autoUpdateProvider", + label: /* @__PURE__ */ jsx(Trans, { message: "Fetch content from" }), + required: true, + children: [ + /* @__PURE__ */ jsx(Item, { value: "tmdb", children: /* @__PURE__ */ jsx(Trans, { message: "TheMovieDB" }) }), + /* @__PURE__ */ jsx(Item, { value: "local", children: /* @__PURE__ */ jsx(Trans, { message: "Local database" }) }) + ] + } + ) }); +} +const KEYWORD_MODEL = "keyword"; +const supportedModels = [TITLE_MODEL, MOVIE_MODEL, SERIES_MODEL]; +const restrictions = { + [GENRE_MODEL]: message("Genre"), + [KEYWORD_MODEL]: message("Keyword"), + [PRODUCTION_COUNTRY_MODEL]: message("Production country") +}; +function ChannelRestrictionField({ className }) { + const { setValue } = useFormContext(); + const { watch } = useFormContext(); + if (!supportedModels.includes(watch("config.contentModel"))) { + return null; + } + return /* @__PURE__ */ jsxs("div", { className: clsx("items-end gap-14 md:flex", className), children: [ + /* @__PURE__ */ jsxs( + FormSelect, + { + className: "w-full flex-auto", + name: "config.restriction", + selectionMode: "single", + label: /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Filter titles by" }), + /* @__PURE__ */ jsx(InfoTrigger$1, {}) + ] }), + onSelectionChange: () => { + setValue("config.restrictionModelId", "urlParam"); + }, + children: [ + /* @__PURE__ */ jsx(Item, { value: null, children: /* @__PURE__ */ jsx(Trans, { message: "Don't filter titles" }) }), + Object.entries(restrictions).map(([value, label]) => /* @__PURE__ */ jsx(Item, { value, children: /* @__PURE__ */ jsx(Trans, { ...label }) }, value)) + ] + } + ), + /* @__PURE__ */ jsx(RestrictionModelField, {}) + ] }); +} +function RestrictionModelField() { + var _a2, _b; + const { trans } = useTrans(); + const [searchValue, setSearchValue] = useState(""); + const { watch } = useFormContext(); + const { data } = useValueLists(["genres", "productionCountries"], { + type: watch("config.autoUpdateProvider") + }); + const selectedRestriction = watch( + "config.restriction" + ); + const selectedKeywordId = watch("config.restrictionModelId"); + const keywordQuery = useValueLists(["keywords"], { + searchQuery: searchValue, + selectedValue: selectedKeywordId, + type: watch("config.autoUpdateProvider") + }); + if (!selectedRestriction) + return null; + const options = { + [GENRE_MODEL]: data == null ? void 0 : data.genres, + [KEYWORD_MODEL]: (_a2 = keywordQuery.data) == null ? void 0 : _a2.keywords, + [PRODUCTION_COUNTRY_MODEL]: data == null ? void 0 : data.productionCountries + }; + const restrictionLabel = restrictions[selectedRestriction]; + return /* @__PURE__ */ jsxs( + FormSelect, + { + className: "w-full flex-auto", + name: "config.restrictionModelId", + selectionMode: "single", + showSearchField: true, + searchPlaceholder: trans(message("Search...")), + isAsync: selectedRestriction === KEYWORD_MODEL, + isLoading: selectedRestriction === KEYWORD_MODEL && keywordQuery.isLoading, + inputValue: searchValue, + onInputValueChange: setSearchValue, + label: /* @__PURE__ */ jsx( + Trans, + { + message: ":restriction name", + values: { restriction: trans(restrictionLabel) } + } + ), + children: [ + /* @__PURE__ */ jsx(Item, { value: "urlParam", children: /* @__PURE__ */ jsx(Trans, { message: "Dynamic (from url)" }) }), + (_b = options[selectedRestriction]) == null ? void 0 : _b.map((option) => /* @__PURE__ */ jsx(Item, { value: option.value, children: /* @__PURE__ */ jsx(Trans, { message: option.name }) }, option.value)) + ] + } + ); +} +function InfoTrigger$1() { + return /* @__PURE__ */ jsx( + InfoDialogTrigger, + { + body: /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Allows specifying additional condition channel content should be filtered on. " }), + /* @__PURE__ */ jsx(ChannelsDocsLink, { className: "mt-20", hash: "filter-titles-by" }) + ] }) + } + ); +} +const DashboardIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M19 5v2h-4V5h4M9 5v6H5V5h4m10 8v6h-4v-6h4M9 17v2H5v-2h4M21 3h-8v6h8V3zM11 3H3v10h8V3zm10 8h-8v10h8V11zm-10 4H3v6h8v-6z" }), + "DashboardOutlined" +); +function ContentLayoutFields({ config, className }) { + return /* @__PURE__ */ jsxs("div", { className: clsx("items-end gap-14 md:flex", className), children: [ + /* @__PURE__ */ jsx( + LayoutField, + { + config, + name: "config.layout", + label: /* @__PURE__ */ jsx(Trans, { message: "Layout" }) + } + ), + /* @__PURE__ */ jsx( + LayoutField, + { + config, + name: "config.nestedLayout", + label: /* @__PURE__ */ jsx(Trans, { message: "Layout when nested" }) + } + ) + ] }); +} +function LayoutField({ config, name, label }) { + var _a2; + const { watch } = useFormContext(); + const contentModel = watch("config.contentModel"); + const modelConfig = config.models[contentModel]; + if (!((_a2 = modelConfig.layoutMethods) == null ? void 0 : _a2.length)) { + return null; + } + return /* @__PURE__ */ jsx( + FormSelect, + { + className: "w-full flex-auto", + selectionMode: "single", + name, + label, + children: modelConfig.layoutMethods.map((method) => { + const label2 = config.layoutMethods[method].label; + return /* @__PURE__ */ jsx(Item, { value: method, children: /* @__PURE__ */ jsx(Trans, { ...label2 }) }, method); + }) + } + ); +} +function ChannelPaginationTypeField({ className }) { + return /* @__PURE__ */ jsxs( + FormSelect, + { + className, + selectionMode: "single", + name: "config.paginationType", + label: /* @__PURE__ */ jsx(Trans, { message: "Pagination type" }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "infiniteScroll", children: /* @__PURE__ */ jsx(Trans, { message: "Infinite scroll" }) }), + /* @__PURE__ */ jsx(Item, { value: "lengthAware", children: /* @__PURE__ */ jsx(Trans, { message: "List of page buttons" }) }), + /* @__PURE__ */ jsx(Item, { value: "simple", children: /* @__PURE__ */ jsx(Trans, { message: "Next/previous page buttons only" }) }) + ] + } + ); +} +const PublicIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-.61.08-1.21.21-1.78L8.99 15v1c0 1.1.9 2 2 2v1.93C7.06 19.43 4 16.07 4 12zm13.89 5.4c-.26-.81-1-1.4-1.9-1.4h-1v-3c0-.55-.45-1-1-1h-6v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41C17.92 5.77 20 8.65 20 12c0 2.08-.81 3.98-2.11 5.4z" }), + "PublicOutlined" +); +function ChannelSeoFields() { + const { trans } = useTrans(); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "config.seoTitle", + label: /* @__PURE__ */ jsx(Trans, { message: "SEO title" }), + className: "mb-24", + placeholder: trans(message("Optional")) + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "config.seoDescription", + label: /* @__PURE__ */ jsx(Trans, { message: "SEO description" }), + inputElementType: "textarea", + rows: 6, + placeholder: trans(message("Optional")) + } + ) + ] }); +} +function EditChannelPage() { + return /* @__PURE__ */ jsx(EditChannelPageLayout, { children: /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs(Accordion, { variant: "outline", children: [ + /* @__PURE__ */ jsxs( + AccordionItem, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Title & description" }), + startIcon: /* @__PURE__ */ jsx(DescriptionIcon, {}), + children: [ + /* @__PURE__ */ jsx(ChannelNameField, {}), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mt-24", + name: "config.hideTitle", + description: /* @__PURE__ */ jsx(Trans, { message: "Whether title should be shown when displaying this channel on the site." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Hide title" }) + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "description", + label: /* @__PURE__ */ jsx(Trans, { message: "Description" }), + inputElementType: "textarea", + rows: 1, + className: "mt-24" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "config.adminDescription", + label: /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(Trans, { message: "Internal description" }), + /* @__PURE__ */ jsx( + InfoDialogTrigger, + { + body: /* @__PURE__ */ jsx(Trans, { message: "This describes the purpose of the channel and is only visible in admin area." }) + } + ) + ] }), + inputElementType: "textarea", + rows: 1, + className: "mt-24" + } + ) + ] + } + ), + /* @__PURE__ */ jsxs( + AccordionItem, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Content settings" }), + startIcon: /* @__PURE__ */ jsx(SettingsIcon, {}), + children: [ + /* @__PURE__ */ jsx(ContentTypeField, { config: channelContentConfig, className: "mb-24" }), + /* @__PURE__ */ jsx(ChannelAutoUpdateField, { className: "mb-24" }), + /* @__PURE__ */ jsx( + ContentModelField, + { + config: channelContentConfig, + className: "mb-24" + } + ), + /* @__PURE__ */ jsx(ChannelRestrictionField, { className: "mb-24" }), + /* @__PURE__ */ jsx(ContentOrderField, { config: channelContentConfig }) + ] + } + ), + /* @__PURE__ */ jsxs( + AccordionItem, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Layout" }), + startIcon: /* @__PURE__ */ jsx(DashboardIcon, {}), + children: [ + /* @__PURE__ */ jsx( + ContentLayoutFields, + { + config: channelContentConfig, + className: "mb-24" + } + ), + /* @__PURE__ */ jsx(ChannelPaginationTypeField, { config: channelContentConfig }) + ] + } + ), + /* @__PURE__ */ jsx( + AccordionItem, + { + label: /* @__PURE__ */ jsx(Trans, { message: "SEO" }), + startIcon: /* @__PURE__ */ jsx(PublicIcon, {}), + children: /* @__PURE__ */ jsx(ChannelSeoFields, {}) + } + ) + ] }), + /* @__PURE__ */ jsx(ChannelContentEditor, { searchField: /* @__PURE__ */ jsx(SearchField, {}) }) + ] }) }); +} +function SearchField(props) { + return /* @__PURE__ */ jsx( + ChannelContentSearchField, + { + ...props, + imgRenderer: (item) => /* @__PURE__ */ jsx(ChannelContentItemImage, { item }) + } + ); +} +const endpoint$1 = "channel"; +function useCreateChannel(form) { + const { trans } = useTrans(); + const navigate = useNavigate$1(); + const queryClient2 = useQueryClient(); + return useMutation({ + mutationFn: (payload) => createChannel(payload), + onSuccess: async (response) => { + await queryClient2.invalidateQueries({ + queryKey: DatatableDataQueryKey(endpoint$1) + }); + toast(trans(message("Channel created"))); + navigate(`/admin/channels/${response.channel.id}/edit`, { + replace: true + }); + }, + onError: (err) => onFormQueryError(err, form) + }); +} +function createChannel(payload) { + return apiClient.post(endpoint$1, payload).then((r) => r.data); +} +function CreateChannelPageLayout({ defaultValues, children }) { + const form = useForm({ + defaultValues: { + content: EMPTY_PAGINATION_RESPONSE.pagination, + config: { + contentType: "listAll", + contentOrder: "created_at:desc", + nestedLayout: "carousel", + ...defaultValues + } + } + }); + const createChannel2 = useCreateChannel(form); + return /* @__PURE__ */ jsx( + CrupdateResourceLayout, + { + form, + onSubmit: (values) => { + createChannel2.mutate(values); + }, + title: /* @__PURE__ */ jsx(Trans, { message: "Add new channel" }), + isLoading: createChannel2.isPending, + children + } + ); +} +function CreateChannelPage() { + return /* @__PURE__ */ jsx( + CreateChannelPageLayout, + { + defaultValues: { + contentModel: MOVIE_MODEL, + autoUpdateProvider: "local", + layout: "grid", + nestedLayout: "carousel", + paginationType: "infiniteScroll" + }, + children: /* @__PURE__ */ jsxs(Tabs, { children: [ + /* @__PURE__ */ jsxs(TabList, { children: [ + /* @__PURE__ */ jsx(Tab, { children: /* @__PURE__ */ jsx(Trans, { message: "Settings" }) }), + /* @__PURE__ */ jsx(Tab, { children: /* @__PURE__ */ jsx(Trans, { message: "SEO" }) }) + ] }), + /* @__PURE__ */ jsxs(TabPanels, { className: "pt-24", children: [ + /* @__PURE__ */ jsxs(TabPanel, { children: [ + /* @__PURE__ */ jsx(ChannelNameField, {}), + /* @__PURE__ */ jsx( + FormSwitch, + { + className: "mt-24", + name: "config.hideTitle", + description: /* @__PURE__ */ jsx(Trans, { message: "Whether title should be shown when displaying this channel on the site." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Hide title" }) + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "description", + label: /* @__PURE__ */ jsx(Trans, { message: "Description" }), + inputElementType: "textarea", + rows: 2, + className: "my-24" + } + ), + /* @__PURE__ */ jsx(ContentTypeField, { config: channelContentConfig, className: "mb-24" }), + /* @__PURE__ */ jsx(ChannelAutoUpdateField, { className: "mb-24" }), + /* @__PURE__ */ jsx( + ContentModelField, + { + config: channelContentConfig, + className: "mb-24" + } + ), + /* @__PURE__ */ jsx(ChannelRestrictionField, { className: "mb-24" }), + /* @__PURE__ */ jsx(ContentOrderField, { config: channelContentConfig }), + /* @__PURE__ */ jsx( + ContentLayoutFields, + { + config: channelContentConfig, + className: "my-24" + } + ), + /* @__PURE__ */ jsx( + ChannelPaginationTypeField, + { + config: channelContentConfig, + className: "mb-24" + } + ) + ] }), + /* @__PURE__ */ jsx(TabPanel, { children: /* @__PURE__ */ jsx(ChannelSeoFields, {}) }) + ] }) + ] }) + } + ); +} +const NewsDatatableFilters = [ + createdAtFilter({ + description: message("Date article was created") + }), + updatedAtFilter({ + description: message("Date article was last updated") + }) +]; +const onlineArticlesImg = "/assets/online-articles-1ec8936a.svg"; +function useDeleteNewsArticle() { + return useMutation({ + mutationFn: (payload) => deleteArticle(payload), + onError: (err) => showHttpErrorToast(err), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("news") + }); + toast(message("Article deleted")); + } + }); +} +function deleteArticle(payload) { + return apiClient.delete(`news/${payload.articleId}`).then((r) => r.data); +} +const newsDatatableColumns = [ + { + key: "name", + width: "flex-3 min-w-200", + visibleInMode: "all", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Title" }), + body: (article) => /* @__PURE__ */ jsx(ArticleColumn, { article }) + }, + { + key: "updatedAt", + allowsSorting: true, + width: "w-96", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (article) => /* @__PURE__ */ jsx("time", { children: /* @__PURE__ */ jsx(FormattedDate, { date: article.updated_at }) }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + width: "w-84 flex-shrink-0", + hideHeader: true, + align: "end", + visibleInMode: "all", + body: (article) => /* @__PURE__ */ jsxs("div", { className: "text-muted", children: [ + /* @__PURE__ */ jsx(Link, { to: `${article.id}/edit`, children: /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Edit article" }), children: /* @__PURE__ */ jsx(IconButton, { size: "md", children: /* @__PURE__ */ jsx(EditIcon, {}) }) }) }), + /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Delete article" }), children: /* @__PURE__ */ jsx(IconButton, { children: /* @__PURE__ */ jsx(DeleteIcon, {}) }) }), + /* @__PURE__ */ jsx(DeleteArticleDialog, { article }) + ] }) + ] }) + } +]; +function ArticleColumn({ article }) { + const { isCollapsedMode } = useContext(TableContext); + return /* @__PURE__ */ jsxs("div", { className: "flex gap-14", children: [ + /* @__PURE__ */ jsx(NewsArticleImage, { article, size: "w-52 h-52", lazy: false }), + /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [ + /* @__PURE__ */ jsx( + "div", + { + className: clsx( + isCollapsedMode ? "whitespace-normal" : "font-medium whitespace-nowrap overflow-hidden overflow-ellipsis" + ), + children: /* @__PURE__ */ jsx(NewsArticleLink, { article, target: "_blank" }) + } + ), + !isCollapsedMode && /* @__PURE__ */ jsx("p", { className: "text-muted mt-4 text-xs max-w-680 whitespace-normal", children: article.body }) + ] }) + ] }); +} +function DeleteArticleDialog({ article }) { + const deleteArticle2 = useDeleteNewsArticle(); + const { close } = useDialogContext(); + return /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isDanger: true, + isLoading: deleteArticle2.isPending, + title: /* @__PURE__ */ jsx(Trans, { message: "Delete article" }), + body: /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete this article?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }), + onConfirm: () => { + deleteArticle2.mutate( + { articleId: article.id }, + { onSuccess: () => close() } + ); + } + } + ); +} +const PublishIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M5 4h14v2H5zm0 10h4v6h6v-6h4l-7-7-7 7zm8-2v6h-2v-6H9.83L12 9.83 14.17 12H13z" }), + "PublishOutlined" +); +function useImportNewsArticles() { + return useMutation({ + mutationFn: () => importArticles(), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["news"] }); + toast(message("Imported news articles")); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function importArticles() { + return apiClient.post(`news/import-from-remote-provider`).then((r) => r.data); +} +function NewsDatatablePage() { + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "news", + title: /* @__PURE__ */ jsx(Trans, { message: "News articles" }), + filters: NewsDatatableFilters, + columns: newsDatatableColumns, + queryParams: { + stripHtml: "true", + truncateBody: 200 + }, + actions: /* @__PURE__ */ jsx(Actions$8, {}), + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + enableSelection: false, + cellHeight: "h-80", + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: onlineArticlesImg, + title: /* @__PURE__ */ jsx(Trans, { message: "No articles have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching articles" }) + } + ) + } + ); +} +function Actions$8() { + const importArticles2 = useImportNewsArticles(); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Import news articles" }), children: /* @__PURE__ */ jsx( + IconButton, + { + variant: "outline", + color: "primary", + size: "sm", + onClick: () => importArticles2.mutate(), + disabled: importArticles2.isPending, + children: /* @__PURE__ */ jsx(PublishIcon, {}) + } + ) }), + /* @__PURE__ */ jsx(DataTableAddItemButton, { elementType: Link, to: "add", children: /* @__PURE__ */ jsx(Trans, { message: "Add news article" }) }) + ] }); +} +function DeleteCommentsButton({ + commentIds, + variant = "outline", + size = "xs" +}) { + const deleteComments = useDeleteComments(); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (isConfirmed) => { + if (isConfirmed) { + deleteComments.mutate( + { commentIds }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["comment"] }); + } + } + ); + } + }, + children: [ + /* @__PURE__ */ jsx( + Button, + { + variant, + size, + color: "danger", + className: "mr-10", + disabled: deleteComments.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isDanger: true, + title: /* @__PURE__ */ jsx( + Trans, + { + message: "Delete [one comment|other :count comments]", + values: { count: commentIds.length } + } + ), + body: commentIds.length > 1 ? /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete selected comments?" }) : /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete this comment?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ) + ] + } + ); +} +function useUpdateComment() { + return useMutation({ + mutationFn: (props) => updateComment(props), + onSuccess: () => { + toast(message("Comment updated")); + queryClient.invalidateQueries({ queryKey: ["comment"] }); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function updateComment({ commentId, content }) { + return apiClient.put(`comment/${commentId}`, { content }).then((r) => r.data); +} +function useRestoreComments() { + return useMutation({ + mutationFn: (payload) => restoreComment(payload), + onSuccess: (response, payload) => { + toast( + message("Restored [one 1 comment|other :count comments]", { + values: { count: payload.commentIds.length } + }) + ); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function restoreComment({ commentIds }) { + return apiClient.post("comment/restore", { commentIds }).then((r) => r.data); +} +function RestoreCommentsButton({ + commentIds, + variant = "outline", + size = "xs" +}) { + const restoreComments = useRestoreComments(); + return /* @__PURE__ */ jsx( + Button, + { + variant, + size, + className: "mr-10", + disabled: restoreComments.isPending, + color: "primary", + onClick: () => { + restoreComments.mutate( + { commentIds }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["comment"] }); + } + } + ); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Restore" }) + } + ); +} +function CommentDatatableItem({ comment, isSelected, onToggle }) { + const [isEditing, setIsEditing] = useState(false); + return /* @__PURE__ */ jsxs("div", { className: clsx("p-14 border-b", comment.deleted && "bg-danger/6"), children: [ + comment.commentable && /* @__PURE__ */ jsx( + CommentableHeader, + { + isSelected, + onToggle, + commentable: comment.commentable + } + ), + /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-10 pt-14 md:pl-20", children: [ + /* @__PURE__ */ jsx(UserAvatar, { className: "flex-shrink-0", user: comment.user, size: "md" }), + /* @__PURE__ */ jsxs("div", { className: "flex-auto", children: [ + /* @__PURE__ */ jsx(CommentHeader, { comment }), + isEditing ? /* @__PURE__ */ jsx( + EditCommentForm, + { + comment, + onClose: (isSaved) => { + setIsEditing(false); + if (isSaved) { + queryClient.invalidateQueries({ queryKey: ["comment"] }); + } + } + } + ) : /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx("div", { className: "text-sm my-14", children: comment.content }), + /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-24 justify-between", children: [ + /* @__PURE__ */ jsxs("div", { children: [ + comment.deleted ? /* @__PURE__ */ jsx(RestoreCommentsButton, { commentIds: [comment.id] }) : /* @__PURE__ */ jsx(DeleteCommentsButton, { commentIds: [comment.id] }), + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + size: "xs", + onClick: () => { + setIsEditing(true); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Edit" }) + } + ) + ] }), + /* @__PURE__ */ jsx("div", { className: "text-xs text-danger", children: /* @__PURE__ */ jsx( + Trans, + { + message: "Reported [one 1 time|other :count times]", + values: { count: comment.reports_count } + } + ) }) + ] }) + ] }) + ] }) + ] }) + ] }); +} +function CommentableHeader({ + isSelected, + onToggle, + commentable +}) { + return /* @__PURE__ */ jsxs("div", { className: "flex items-center", children: [ + /* @__PURE__ */ jsx("div", { className: "mr-14", children: /* @__PURE__ */ jsx(Checkbox, { checked: isSelected, onChange: () => onToggle() }) }), + commentable.image && /* @__PURE__ */ jsx( + "img", + { + className: "w-20 h-20 rounded overflow-hidden object-cover mr-6", + src: commentable.image, + alt: "" + } + ), + /* @__PURE__ */ jsx("div", { className: "text-sm mr-4", children: commentable.name }), + /* @__PURE__ */ jsxs("div", { className: "text-muted text-xs", children: [ + "(", + commentable.model_type, + ")" + ] }) + ] }); +} +function CommentHeader({ comment }) { + return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4 text-sm", children: [ + /* @__PURE__ */ jsx("div", { children: comment.user && /* @__PURE__ */ jsx(UserDisplayName$1, { user: comment.user, show: "display_name" }) }), + /* @__PURE__ */ jsx("div", { children: "•" }), + /* @__PURE__ */ jsx("time", { children: /* @__PURE__ */ jsx(FormattedRelativeTime, { date: comment.created_at }) }), + comment.user && /* @__PURE__ */ jsx("div", { className: "ml-auto hidden md:block", children: /* @__PURE__ */ jsx(UserDisplayName$1, { user: comment.user, show: "email" }) }) + ] }); +} +function EditCommentForm({ comment, onClose }) { + const [content, setContent] = useState(comment.content); + const updateComment2 = useUpdateComment(); + return /* @__PURE__ */ jsxs( + "form", + { + onSubmit: (e) => { + e.preventDefault(); + updateComment2.mutate( + { commentId: comment.id, content }, + { onSuccess: () => onClose(true) } + ); + }, + children: [ + /* @__PURE__ */ jsx( + TextField, + { + autoFocus: true, + inputElementType: "textarea", + className: "my-14", + rows: 2, + value: content, + onChange: (e) => setContent(e.target.value) + } + ), + /* @__PURE__ */ jsx( + Button, + { + size: "xs", + variant: "outline", + color: "primary", + type: "submit", + className: "mr-6", + disabled: updateComment2.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Save edit" }) + } + ), + /* @__PURE__ */ jsx( + Button, + { + size: "xs", + variant: "outline", + className: "mr-6", + onClick: (e) => onClose(false), + disabled: updateComment2.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) + } + ) + ] + } + ); +} +function UserDisplayName$1({ user, show }) { + const { auth } = useContext(SiteConfigContext); + if (auth.getUserProfileLink) { + return /* @__PURE__ */ jsx( + Link, + { + to: auth.getUserProfileLink(user), + className: LinkStyle, + target: "_blank", + children: user[show] + } + ); + } + return /* @__PURE__ */ jsx("div", { className: "text-muted", children: user[show] }); +} +const publicDiscussionsImage = "/assets/public-discussion-7f74dab5.svg"; +const CommentsDatatableFilters = [ + { + key: "deleted", + label: message("Status"), + description: message("Whether comment is active or deleted"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "01", + options: [ + { + key: "01", + label: message("Active"), + value: false + }, + { + key: "02", + label: message("Deleted"), + value: true + } + ] + } + }, + { + key: "reports", + label: message("Reported"), + description: message("Show only reported comments"), + defaultOperator: FilterOperator.has, + control: { + type: FilterControlType.BooleanToggle, + defaultValue: "*" + } + }, + { + key: "user_id", + label: message("User"), + description: message("User comment was created by"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.SelectModel, + model: USER_MODEL + } + }, + createdAtFilter({ + description: message("Date comment was created") + }), + updatedAtFilter({ + description: message("Date comment was last updated") + }) +]; +function CommentsDatatablePage({ hideTitle, commentable }) { + var _a2; + const filters = useMemo(() => { + return CommentsDatatableFilters.filter( + (f) => f.key !== "commentable_id" || !commentable + ); + }, [commentable]); + const { encodedFilters } = useBackendFilterUrlParams(filters); + const [params, setParams] = useState({ perPage: 15 }); + const [selectedComments, setSelectedComments] = useState([]); + const query = useDatatableData( + "comment", + { + ...params, + with: "commentable", + withCount: "reports", + filters: encodedFilters, + commentable_type: commentable == null ? void 0 : commentable.model_type, + commentable_id: commentable == null ? void 0 : commentable.id + }, + void 0, + () => { + setSelectedComments([]); + } + ); + const toggleComment = useCallback( + (id) => { + const newValues = [...selectedComments]; + if (!newValues.includes(id)) { + newValues.push(id); + } else { + const index = newValues.indexOf(id); + newValues.splice(index, 1); + } + setSelectedComments(newValues); + }, + [selectedComments, setSelectedComments] + ); + const isFiltering = !!(params.query || params.filters || encodedFilters); + const pagination = (_a2 = query.data) == null ? void 0 : _a2.pagination; + return /* @__PURE__ */ jsxs("div", { className: clsx(!hideTitle && "p-12 md:p-24"), children: [ + /* @__PURE__ */ jsxs("div", { className: clsx("mb-16"), children: [ + /* @__PURE__ */ jsx(StaticPageTitle, { children: /* @__PURE__ */ jsx(Trans, { message: "Comments" }) }), + !hideTitle && /* @__PURE__ */ jsx("h1", { className: "text-3xl font-light", children: /* @__PURE__ */ jsx(Trans, { message: "Comments" }) }) + ] }), + /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx(AnimatePresence, { initial: false, mode: "wait", children: selectedComments.length ? /* @__PURE__ */ jsx( + SelectedStateDatatableHeader, + { + selectedItemsCount: selectedComments.length, + actions: /* @__PURE__ */ jsx( + DeleteCommentsButton, + { + size: "sm", + variant: "flat", + commentIds: selectedComments + } + ) + }, + "selected" + ) : /* @__PURE__ */ jsx( + DataTableHeader, + { + filters, + searchValue: params.query, + onSearchChange: (query2) => setParams({ ...params, query: query2 }) + }, + "default" + ) }), + /* @__PURE__ */ jsx(FilterList, { className: "mb-14", filters }), + query.isLoading ? /* @__PURE__ */ jsx(FullPageLoader, { className: "min-h-200" }) : /* @__PURE__ */ jsx("div", { className: "rounded border-x border-t", children: pagination == null ? void 0 : pagination.data.map((comment) => /* @__PURE__ */ jsx( + CommentDatatableItem, + { + comment, + isSelected: selectedComments.includes(comment.id), + onToggle: () => toggleComment(comment.id) + }, + comment.id + )) }), + (query.isFetched || query.isPlaceholderData) && !(pagination == null ? void 0 : pagination.data.length) ? /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + className: "pt-50", + isFiltering, + image: publicDiscussionsImage, + title: /* @__PURE__ */ jsx(Trans, { message: "No comments have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching comments" }) + } + ) : void 0, + /* @__PURE__ */ jsx( + DataTablePaginationFooter, + { + className: "mt-10", + query, + onPageChange: (page) => setParams({ ...params, page }), + onPerPageChange: (perPage) => setParams({ ...params, perPage }) + } + ) + ] }) + ] }); +} +const reviewsImage = "/assets/reviews-b904b01e.svg"; +function DeleteReviewsButton({ + reviewIds, + variant = "outline", + size = "xs" +}) { + const deleteReviews = useDeleteReviews(); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (isConfirmed) => { + if (isConfirmed) { + deleteReviews.mutate({ reviewIds }); + } + }, + children: [ + /* @__PURE__ */ jsx( + Button, + { + variant, + size, + color: "danger", + className: "mr-10", + disabled: deleteReviews.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isDanger: true, + title: /* @__PURE__ */ jsx( + Trans, + { + message: "Delete [one review|other :count reviews]", + values: { count: reviewIds.length } + } + ), + body: reviewIds.length > 1 ? /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete selected reviews?" }) : /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete this review?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ) + ] + } + ); +} +function useUpdateReview(review, form) { + return useMutation({ + mutationFn: (payload) => updateReview(review, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["reviews"] }); + toast(message("Review updated")); + }, + onError: (r) => form ? onFormQueryError(r, form) : showHttpErrorToast(r) + }); +} +function updateReview(review, payload) { + return apiClient.put(`reviews/${review.id}`, { + score: payload.score, + title: payload.title, + body: payload.body + }).then((r) => r.data); +} +function ReviewDatatableItem({ review, isSelected, onToggle }) { + const [isEditing, setIsEditing] = useState(false); + const helpfulCount = review.helpful_count || 1; + const totalFeedbackCount = review.helpful_count + review.not_helpful_count || 1; + return /* @__PURE__ */ jsxs("div", { className: "border-b p-14", children: [ + review.reviewable && /* @__PURE__ */ jsx( + ReviewableHeader, + { + isSelected, + onToggle, + reviewable: review.reviewable + } + ), + /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-10 pt-14 md:pl-20", children: [ + /* @__PURE__ */ jsx(UserAvatar, { className: "flex-shrink-0", user: review.user, size: "md" }), + /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-auto overflow-hidden", children: [ + /* @__PURE__ */ jsx(ReviewHeader, { review }), + isEditing ? /* @__PURE__ */ jsx( + EditReviewForm, + { + review, + onClose: (isSaved) => { + setIsEditing(false); + if (isSaved) { + queryClient.invalidateQueries({ queryKey: ["comment"] }); + } + } + } + ) : /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs("div", { className: "my-14", children: [ + /* @__PURE__ */ jsx(TitleRating, { className: "mb-8", score: review.score }), + review.title && /* @__PURE__ */ jsx("div", { className: "mb-8 text-base font-medium", children: review.title }), + /* @__PURE__ */ jsx("div", { className: "whitespace-break-spaces text-sm", children: review.body }), + /* @__PURE__ */ jsx("div", { className: "mt-8 text-xs text-muted", children: /* @__PURE__ */ jsxs(BulletSeparatedItems, { children: [ + /* @__PURE__ */ jsx( + Trans, + { + message: ":helpfulCount out of :total people found this helpful", + values: { helpfulCount, total: totalFeedbackCount } + } + ), + review.reports_count ? /* @__PURE__ */ jsx( + Trans, + { + message: ":count reports", + values: { count: review.reports_count || 0 } + } + ) : null + ] }) }) + ] }), + /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx(DeleteReviewsButton, { reviewIds: [review.id] }), + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + size: "xs", + onClick: () => setIsEditing(true), + children: /* @__PURE__ */ jsx(Trans, { message: "Edit" }) + } + ) + ] }) + ] }) + ] }) + ] }) + ] }); +} +function ReviewableHeader({ + isSelected, + onToggle, + reviewable +}) { + return /* @__PURE__ */ jsxs("div", { className: "flex items-center", children: [ + /* @__PURE__ */ jsx("div", { className: "mr-14", children: /* @__PURE__ */ jsx(Checkbox, { checked: isSelected, onChange: () => onToggle() }) }), + reviewable.image && /* @__PURE__ */ jsx( + "img", + { + className: "mr-6 h-20 w-20 overflow-hidden rounded object-cover", + src: reviewable.image, + alt: "" + } + ), + /* @__PURE__ */ jsx("div", { className: "mr-4 text-sm", children: reviewable.name }), + /* @__PURE__ */ jsxs("div", { className: "text-xs text-muted", children: [ + "(", + reviewable.model_type, + ")" + ] }) + ] }); +} +function ReviewHeader({ review }) { + return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4 text-sm", children: [ + /* @__PURE__ */ jsx("div", { children: review.user && /* @__PURE__ */ jsx(UserDisplayName, { user: review.user, show: "display_name" }) }), + /* @__PURE__ */ jsx("div", { children: "•" }), + /* @__PURE__ */ jsx("time", { children: /* @__PURE__ */ jsx(FormattedRelativeTime, { date: review.created_at }) }), + review.user && /* @__PURE__ */ jsx("div", { className: "ml-auto hidden md:block", children: /* @__PURE__ */ jsx(UserDisplayName, { user: review.user, show: "email" }) }) + ] }); +} +function EditReviewForm({ review, onClose }) { + useState(review.body); + const updateReview2 = useUpdateReview(review); + const form = useForm({ + defaultValues: { + score: review.score, + title: review.title, + body: review.body + } + }); + return /* @__PURE__ */ jsxs( + Form$1, + { + className: "mt-24", + form, + onSubmit: (newValues) => { + updateReview2.mutate(newValues, { onSuccess: () => onClose(true) }); + }, + children: [ + /* @__PURE__ */ jsx( + StarSelector, + { + className: "-ml-8 mb-12", + count: 10, + value: form.watch("score"), + onValueChange: (newScore) => { + form.setValue("score", newScore); + } + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "title", + className: "mb-24", + label: /* @__PURE__ */ jsx(Trans, { message: "Title" }), + labelSuffix: /* @__PURE__ */ jsx(Trans, { message: "10 character minimum" }), + autoFocus: true, + minLength: 10, + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + className: "mb-24", + name: "body", + label: /* @__PURE__ */ jsx(Trans, { message: "Review" }), + labelSuffix: /* @__PURE__ */ jsx(Trans, { message: "100 character minimum" }), + inputElementType: "textarea", + rows: 5, + minLength: 100, + required: true + } + ), + /* @__PURE__ */ jsx( + Button, + { + size: "xs", + variant: "outline", + color: "primary", + type: "submit", + className: "mr-6", + disabled: updateReview2.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ), + /* @__PURE__ */ jsx( + Button, + { + size: "xs", + variant: "outline", + className: "mr-6", + onClick: (e) => onClose(false), + disabled: updateReview2.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) + } + ) + ] + } + ); +} +function UserDisplayName({ user, show }) { + const { auth } = useContext(SiteConfigContext); + if (auth.getUserProfileLink) { + return /* @__PURE__ */ jsx( + Link, + { + to: auth.getUserProfileLink(user), + className: LinkStyle, + target: "_blank", + children: user[show] + } + ); + } + return /* @__PURE__ */ jsx("div", { className: "text-muted", children: user[show] }); +} +const ReviewsDatatableFilters = [ + { + key: "user_id", + label: message("User"), + description: message("User review was created by"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.SelectModel, + model: USER_MODEL + } + }, + { + key: "reviewable_id", + label: message("Title"), + description: message("Movie or series review was created for"), + defaultOperator: FilterOperator.eq, + extraFilters: [ + { + key: "reviewable_type", + operator: FilterOperator.eq, + value: "App\\Title" + } + ], + control: { + type: FilterControlType.SelectModel, + model: TITLE_MODEL + } + }, + { + key: "score", + label: message("Score"), + description: message("Review score"), + defaultOperator: FilterOperator.gte, + operators: ALL_PRIMITIVE_OPERATORS, + control: { + type: FilterControlType.Input, + inputType: "number", + minValue: 1, + maxValue: 10, + defaultValue: 7 + } + }, + { + key: "helpful_count", + label: message("Helpful count"), + description: message("How many users found this review helpful"), + defaultOperator: FilterOperator.gte, + operators: ALL_PRIMITIVE_OPERATORS, + control: { + type: FilterControlType.Input, + inputType: "number", + minValue: 1, + defaultValue: 10 + } + }, + { + key: "not_helpful_count", + label: message("Not helpful count"), + description: message("How many users found this review not helpful"), + defaultOperator: FilterOperator.gte, + operators: ALL_PRIMITIVE_OPERATORS, + control: { + type: FilterControlType.Input, + inputType: "number", + minValue: 1, + defaultValue: 10 + } + }, + createdAtFilter({ + description: message("Date review was created") + }), + updatedAtFilter({ + description: message("Date review was last updated") + }) +]; +function ReviewsDatatablePage({ hideTitle, reviewable }) { + var _a2; + const filters = useMemo(() => { + return ReviewsDatatableFilters.filter( + (f) => f.key !== "reviewable_id" || !reviewable + ); + }, [reviewable]); + const { encodedFilters } = useBackendFilterUrlParams(filters); + const [params, setParams] = useState({ perPage: 15 }); + const [selectedReviews, setSelectedReviews] = useState([]); + const [sort, setSort] = useState("created_at:desc"); + const [orderBy, orderDir] = sort.split(":"); + const query = useDatatableData("reviews", { + ...params, + orderBy, + orderDir, + with: "reviewable,user", + filters: encodedFilters, + reviewable_type: reviewable == null ? void 0 : reviewable.model_type, + reviewable_id: reviewable == null ? void 0 : reviewable.id + }, void 0, () => { + setSelectedReviews([]); + }); + const toggleReview = useCallback( + (id) => { + const newValues = [...selectedReviews]; + if (!newValues.includes(id)) { + newValues.push(id); + } else { + const index = newValues.indexOf(id); + newValues.splice(index, 1); + } + setSelectedReviews(newValues); + }, + [selectedReviews, setSelectedReviews] + ); + const isFiltering = !!(params.query || params.filters || encodedFilters); + const pagination = (_a2 = query.data) == null ? void 0 : _a2.pagination; + return /* @__PURE__ */ jsxs("div", { className: clsx(!hideTitle && "p-12 md:p-24"), children: [ + /* @__PURE__ */ jsxs("div", { className: clsx("mb-16"), children: [ + /* @__PURE__ */ jsx(StaticPageTitle, { children: /* @__PURE__ */ jsx(Trans, { message: "Reviews" }) }), + !hideTitle && /* @__PURE__ */ jsx("h1", { className: "text-3xl font-light", children: /* @__PURE__ */ jsx(Trans, { message: "Reviews" }) }) + ] }), + /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx(AnimatePresence, { initial: false, mode: "wait", children: selectedReviews.length ? /* @__PURE__ */ jsx( + SelectedStateDatatableHeader, + { + selectedItemsCount: selectedReviews.length, + actions: /* @__PURE__ */ jsx( + DeleteReviewsButton, + { + size: "sm", + variant: "flat", + reviewIds: selectedReviews + } + ) + }, + "selected" + ) : /* @__PURE__ */ jsx( + DataTableHeader, + { + filters, + searchValue: params.query, + onSearchChange: (query2) => setParams({ ...params, query: query2 }), + actions: /* @__PURE__ */ jsx( + ReviewListSortButton, + { + value: sort, + onValueChange: (newSort) => setSort(newSort), + color: "primary", + showReportsItem: true + } + ) + }, + "default" + ) }), + /* @__PURE__ */ jsx(FilterList, { className: "mb-14", filters }), + query.isLoading ? /* @__PURE__ */ jsx(FullPageLoader, { className: "min-h-200" }) : /* @__PURE__ */ jsx("div", { className: "border-x border-t rounded", children: pagination == null ? void 0 : pagination.data.map((review) => /* @__PURE__ */ jsx( + ReviewDatatableItem, + { + review, + isSelected: selectedReviews.includes(review.id), + onToggle: () => toggleReview(review.id) + }, + review.id + )) }), + (query.isFetched || query.isPlaceholderData) && !(pagination == null ? void 0 : pagination.data.length) ? /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + className: "pt-50", + isFiltering, + image: reviewsImage, + title: /* @__PURE__ */ jsx(Trans, { message: "No reviews have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching reviews" }) + } + ) : void 0, + /* @__PURE__ */ jsx( + DataTablePaginationFooter, + { + className: "mt-10", + query, + onPageChange: (page) => setParams({ ...params, page }), + onPerPageChange: (perPage) => setParams({ ...params, perPage }) + } + ) + ] }) + ] }); +} +const videoFilesImage = "/assets/video-files-a4728f55.svg"; +function BooleanIndicator({ value }) { + if (value) { + return /* @__PURE__ */ jsx(CheckIcon, { className: "icon-md text-positive" }); + } + return /* @__PURE__ */ jsx(CloseIcon, { className: "icon-md text-danger" }); +} +const BarChartIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M5 9.2h3V19H5V9.2zM10.6 5h2.8v14h-2.8V5zm5.6 8H19v6h-2.8v-6z" }), + "BarChartOutlined" +); +const VideosDatatableColumns = [ + { + key: "name", + allowsSorting: true, + width: "flex-3", + visibleInMode: "all", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Video" }), + body: (video) => { + var _a2; + return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-12", children: [ + video.title ? /* @__PURE__ */ jsx( + TitlePoster, + { + title: video.title, + srcSize: "sm", + size: "w-32", + aspect: "aspect-square" + } + ) : null, + /* @__PURE__ */ jsxs("div", { className: "overflow-hidden min-w-0", children: [ + /* @__PURE__ */ jsx("div", { className: "overflow-hidden overflow-ellipsis", children: /* @__PURE__ */ jsxs( + Link, + { + to: getWatchLink(video), + target: "_blank", + className: "hover:underline", + children: [ + (_a2 = video.title) == null ? void 0 : _a2.name, + video.season_num | video.episode_num ? /* @__PURE__ */ jsxs("span", { children: [ + " ", + "(", + /* @__PURE__ */ jsx( + CompactSeasonEpisode, + { + seasonNum: video.season_num, + episodeNum: video.episode_num + } + ), + ")" + ] }) : null + ] + } + ) }), + /* @__PURE__ */ jsx("div", { className: "text-muted text-xs overflow-hidden overflow-ellipsis", children: video.name }) + ] }) + ] }); + } + }, + { + key: "type", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Type" }), + body: (video) => /* @__PURE__ */ jsx("span", { className: "capitalize", children: video.type }) + }, + { + key: "category", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Category" }), + body: (video) => /* @__PURE__ */ jsx("span", { className: "capitalize", children: video.category }) + }, + { + key: "approved", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Approved" }), + body: (video) => /* @__PURE__ */ jsx(BooleanIndicator, { value: video.approved }), + width: "w-80 flex-shrink-0" + }, + { + key: "plays_count", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Plays" }), + body: (video) => video.plays_count ? /* @__PURE__ */ jsx(FormattedNumber, { value: video.plays_count }) : null, + width: "w-80 flex-shrink-0" + }, + { + key: "reports_count", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Reports" }), + body: (video) => video.reports_count ? /* @__PURE__ */ jsx(FormattedNumber, { value: video.reports_count }) : null, + width: "w-80 flex-shrink-0" + }, + { + key: "updated_at", + allowsSorting: true, + maxWidth: "max-w-100", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (video) => video.updated_at ? /* @__PURE__ */ jsx(FormattedDate, { date: video.updated_at }) : "" + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + visibleInMode: "all", + align: "end", + width: "w-84 flex-shrink-0", + body: (video) => /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + className: "text-muted", + elementType: Link, + to: `${video.id}/insights`, + children: /* @__PURE__ */ jsx(BarChartIcon, {}) + } + ), + /* @__PURE__ */ jsx(Link, { to: `${video.id}/edit`, className: "text-muted", children: /* @__PURE__ */ jsx(IconButton, { size: "md", children: /* @__PURE__ */ jsx(EditIcon, {}) }) }) + ] }) + } +]; +function TitleFilterControl(props) { + const { value, filter } = props; + const { isLoading, data } = useNormalizedModel( + `normalized-models/title/${value}` + ); + const skeleton = /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(Skeleton, { variant: "avatar", size: "w-18 h-18 mr-6" }), + /* @__PURE__ */ jsx(Skeleton, { variant: "rect", size: "w-50" }) + ] }); + const modelPreview = /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(Avatar, { size: "xs", src: data == null ? void 0 : data.model.image, className: "mr-6" }), + data == null ? void 0 : data.model.name + ] }); + const label = isLoading || !data ? skeleton : modelPreview; + const Panel = filter.control.panel; + return /* @__PURE__ */ jsx( + FilterListItemDialogTrigger, + { + ...props, + label, + panel: /* @__PURE__ */ jsx(Panel, { filter }) + } + ); +} +function useTitlesAutocomplete(params) { + return useQuery({ + queryKey: ["titles", "autocomplete", params], + queryFn: () => autocompleteTitles(params), + placeholderData: keepPreviousData + }); +} +function autocompleteTitles(params) { + return apiClient.get(`titles/autocomplete`, { params }).then((response) => response.data); +} +function TitleSelect({ + name, + seasonName, + episodeName, + disableTitleField, + className +}) { + var _a2, _b; + const { trans } = useTrans(); + const form = useFormContext(); + const selectedTitleId = form.watch(name); + const [searchQuery, setSearchQuery] = useState(""); + const selectedSeason = seasonName ? form.watch(seasonName) : void 0; + const query = useTitlesAutocomplete({ + searchQuery, + selectedTitleId, + seasonNumber: selectedSeason + }); + const isLoading = query.isLoading || query.isPlaceholderData; + const selectedTitle = (_a2 = query.data) == null ? void 0 : _a2.titles.find((t) => t.id === selectedTitleId); + const seasonCount = (selectedTitle == null ? void 0 : selectedTitle.seasons_count) || 0; + const episodeNumbers = (selectedTitle == null ? void 0 : selectedTitle.episode_numbers) || []; + return /* @__PURE__ */ jsxs("div", { className, children: [ + /* @__PURE__ */ jsx( + FormSelect, + { + selectionMode: "single", + name, + label: /* @__PURE__ */ jsx(Trans, { message: "Title" }), + placeholder: trans(message("Select a title")), + showSearchField: true, + searchPlaceholder: trans(message("Search titles")), + inputValue: searchQuery, + onInputValueChange: setSearchQuery, + isAsync: true, + isLoading, + required: true, + disabled: disableTitleField, + children: (_b = query.data) == null ? void 0 : _b.titles.map((title) => /* @__PURE__ */ jsx( + Item, + { + value: title.id, + description: title.description, + startIcon: /* @__PURE__ */ jsx(Avatar, { src: title.image }), + children: title.name + }, + title.id + )) + } + ), + seasonCount > 0 && seasonName && /* @__PURE__ */ jsxs( + FormSelect, + { + className: "mt-12", + name: seasonName, + placeholder: trans(message("Select a season (optional)")), + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Season" }), + children: [ + /* @__PURE__ */ jsx( + Item, + { + value: "", + onSelected: () => form.resetField(seasonName), + children: /* @__PURE__ */ jsx(Trans, { message: "None" }) + }, + "none" + ), + [...new Array(seasonCount).keys()].map((i) => { + const number = i + 1; + return /* @__PURE__ */ jsx(Item, { value: number, children: /* @__PURE__ */ jsx(Trans, { message: "Season :number", values: { number } }) }, number); + }) + ] + } + ), + !!episodeNumbers.length && episodeName && /* @__PURE__ */ jsxs( + FormSelect, + { + className: "mt-12", + name: episodeName, + placeholder: trans(message("Select an episode (optional)")), + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Episode" }), + children: [ + /* @__PURE__ */ jsx( + Item, + { + value: "", + onSelected: () => form.resetField(episodeName), + children: /* @__PURE__ */ jsx(Trans, { message: "None" }) + }, + "none" + ), + episodeNumbers.map((number) => /* @__PURE__ */ jsx(Item, { value: number, children: /* @__PURE__ */ jsx(Trans, { message: "Episode :number", values: { number } }) }, number)) + ] + } + ) + ] }); +} +function TitleFilterPanel({ filter }) { + return /* @__PURE__ */ jsx( + TitleSelect, + { + name: `${filter.key}.value`, + seasonName: `${filter.key}.season`, + episodeName: `${filter.key}.episode` + } + ); +} +const VideosDatatableFilters = [ + { + key: "user_id", + label: message("User"), + description: message("User video was created by"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.SelectModel, + model: USER_MODEL + } + }, + { + key: "title_id", + label: message("Title"), + description: message("Movie or series video was created for"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Custom, + panel: TitleFilterPanel, + listItem: TitleFilterControl + } + }, + { + key: "approved", + label: message("Status"), + description: message("Whether video is approved or not"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: false, + options: [ + { label: message("Approved"), key: "approved", value: true }, + { label: message("Not approved"), key: "not_approved", value: false } + ] + } + }, + { + key: "origin", + label: message("Origin"), + description: message("Whether video origin is local or external"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "local", + options: [ + { label: message("Local"), key: "local", value: "local" }, + { + label: message("External"), + key: "external", + value: { operator: FilterOperator.ne, value: "local" } + } + ] + } + }, + { + key: "type", + label: message("Type"), + description: message("Type of the video"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "embed", + options: [ + { label: message("Embed"), key: "embed", value: "embed" }, + { label: message("Direct Video"), key: "video", value: "video" }, + { label: message("Stream"), key: "stream", value: "stream" }, + { label: message("Remote Link"), key: "remote", value: "remote" } + ] + } + }, + { + key: "quality", + label: message("Quality"), + description: message("Quality of video"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "hd", + options: [ + { label: message("HD"), key: "hd", value: "hd" }, + { label: message("SD"), key: "sd", value: "sd" }, + { label: message("Stream"), key: "stream", value: "stream" }, + { label: message("Remote Link"), key: "remote", value: "remote" } + ] + } + }, + { + key: "category", + label: message("Category"), + description: message("Video category"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "trailer", + options: [ + { label: message("Trailer"), key: "trailer", value: "trailer" }, + { label: message("Full Movie or episode"), key: "full", value: "full" }, + { label: message("Clip"), key: "clip", value: "clip" }, + { label: message("Teaser"), key: "teaser", value: "teaser" }, + { label: message("Featurette"), key: "featurette", value: "featurette" }, + { + label: message("Behind the scenes"), + key: "behind_the_scenes", + value: "behind the scenes" + } + ] + } + }, + createdAtFilter({ + description: message("Date video was created") + }), + updatedAtFilter({ + description: message("Date video was last updated") + }) +]; +function VideosDatatablePage() { + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "videos", + queryParams: { + withCount: "plays,reports", + with: "episode" + }, + title: /* @__PURE__ */ jsx(Trans, { message: "Videos" }), + columns: VideosDatatableColumns, + filters: VideosDatatableFilters, + actions: /* @__PURE__ */ jsx(Actions$7, {}), + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: videoFilesImage, + title: /* @__PURE__ */ jsx(Trans, { message: "No videos have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching videos" }) + } + ) + } + ); +} +function Actions$7() { + return /* @__PURE__ */ jsx(DataTableAddItemButton, { elementType: Link, to: "new", children: /* @__PURE__ */ jsx(Trans, { message: "Add video" }) }); +} +function useCreateVideo(form) { + return useMutation({ + mutationFn: (payload) => createVideo(payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["video"] }); + }, + onError: (r) => form ? onFormQueryError(r, form) : showHttpErrorToast(r) + }); +} +function createVideo(payload) { + return apiClient.post(`videos`, payload).then((r) => r.data); +} +function useFileEntryModel(entryIdOrUrl, options = { enabled: true }) { + const entryId = extractEntryId(entryIdOrUrl); + return useQuery({ + queryKey: ["file-entries", `${entryId}`], + queryFn: () => fetchFileEntry(entryId), + enabled: !!entryId && options.enabled + }); +} +function fetchFileEntry(entryId) { + return apiClient.get(`file-entries/${entryId}/model`).then((response) => response.data); +} +function extractEntryId(entryIdOrUrl) { + if (!entryIdOrUrl) { + return void 0; + } + const parsedId = parseInt(entryIdOrUrl); + if (!isNaN(parsedId)) { + return parsedId; + } + return `${entryIdOrUrl}`.split("/").pop(); +} +function FileEntryField({ + className, + label, + description, + value, + onChange, + diskPrefix, + disk = Disk.uploads, + showRemoveButton, + invalid, + errorMessage, + required, + autoFocus, + disabled, + allowedFileTypes, + maxFileSize +}) { + const { + uploadFile: uploadFile2, + entry, + uploadStatus, + deleteEntry, + isDeletingEntry, + percentage + } = useActiveUpload(); + const inputRef = useRef(null); + useAutoFocus({ autoFocus }, inputRef); + const { data } = useFileEntryModel(value, { enabled: !entry && !!value }); + const fieldId = useId(); + const labelId = label ? `${fieldId}-label` : void 0; + const descriptionId = description ? `${fieldId}-description` : void 0; + const currentValue = value || (entry == null ? void 0 : entry.url); + const currentEntry = entry || (data == null ? void 0 : data.fileEntry); + const uploadOptions = { + showToastOnRestrictionFail: true, + restrictions: { + allowedFileTypes, + maxFileSize + }, + metadata: { + diskPrefix, + disk + }, + onSuccess: (entry2) => onChange == null ? void 0 : onChange(entry2.url), + onError: (message2) => { + if (message2) { + toast.danger(message2); + } + } + }; + const inputFieldClassNames = getInputFieldClassNames({ + description, + descriptionPosition: "top", + invalid, + disabled: disabled || uploadStatus === "inProgress" + }); + const removeButton = showRemoveButton ? /* @__PURE__ */ jsx( + Button, + { + variant: "link", + color: "danger", + size: "xs", + disabled: isDeletingEntry || !currentValue || disabled, + onClick: () => { + deleteEntry({ + onSuccess: () => onChange == null ? void 0 : onChange("") + }); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Remove file" }) + } + ) : null; + const handleUpload = useCallback(() => { + var _a2; + (_a2 = inputRef.current) == null ? void 0 : _a2.click(); + }, []); + return /* @__PURE__ */ jsxs("div", { className: clsx("text-sm", className), children: [ + label && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-24", children: [ + /* @__PURE__ */ jsx("div", { id: labelId, className: inputFieldClassNames.label, children: label }), + removeButton + ] }), + description && /* @__PURE__ */ jsx("div", { className: inputFieldClassNames.description, children: description }), + /* @__PURE__ */ jsx("div", { "aria-labelledby": labelId, "aria-describedby": descriptionId, children: /* @__PURE__ */ jsxs( + Field, + { + fieldClassNames: inputFieldClassNames, + errorMessage, + invalid, + children: [ + /* @__PURE__ */ jsx( + FileInputField, + { + inputFieldClassNames, + currentValue, + currentEntry, + handleUpload, + children: /* @__PURE__ */ jsx( + "input", + { + ref: inputRef, + "aria-labelledby": labelId, + "aria-describedby": descriptionId, + required: currentValue ? false : required, + accept: allowedFileTypes == null ? void 0 : allowedFileTypes.join(","), + type: "file", + disabled: uploadStatus === "inProgress", + className: "sr-only", + onChange: (e) => { + var _a2; + if ((_a2 = e.target.files) == null ? void 0 : _a2.length) { + const errorMessage2 = validateUpload( + new UploadedFile(e.target.files[0]), + uploadOptions.restrictions + ); + if (errorMessage2 && inputRef.current) { + inputRef.current.value = ""; + toast.danger(errorMessage2); + } else { + uploadFile2(e.target.files[0], uploadOptions); + } + } + } + } + ) + } + ), + uploadStatus === "inProgress" && /* @__PURE__ */ jsx( + ProgressBar, + { + className: "absolute left-0 right-0 top-0", + size: "xs", + value: percentage + } + ) + ] + } + ) }) + ] }); +} +function FileInputField({ + children, + inputFieldClassNames, + currentValue, + currentEntry, + handleUpload +}) { + const buttonRef = useRef(null); + if (currentValue) { + return /* @__PURE__ */ jsx( + Field, + { + wrapperProps: { + onClick: () => { + var _a2, _b; + (_a2 = buttonRef.current) == null ? void 0 : _a2.focus(); + (_b = buttonRef.current) == null ? void 0 : _b.click(); + } + }, + fieldClassNames: inputFieldClassNames, + children: /* @__PURE__ */ jsxs(Input, { className: clsx(inputFieldClassNames.input, "gap-10"), children: [ + /* @__PURE__ */ jsx( + "button", + { + ref: buttonRef, + type: "button", + className: "flex-shrink-0 rounded bg-primary px-10 py-2 text-sm font-semibold text-on-primary outline-none", + onClick: () => handleUpload(), + children: /* @__PURE__ */ jsx(Trans, { message: "Replace file" }) + } + ), + /* @__PURE__ */ jsx(AnimatePresence, { initial: false, mode: "wait", children: /* @__PURE__ */ jsx("div", { className: "min-w-0 overflow-hidden overflow-ellipsis whitespace-nowrap", children: currentEntry ? /* @__PURE__ */ jsx(m.div, { ...opacityAnimation, children: currentEntry.name }, "file-entry-name") : /* @__PURE__ */ jsx(m.div, { ...opacityAnimation, children: /* @__PURE__ */ jsx(Skeleton, { className: "min-w-144" }) }, "skeleton") }) }), + children + ] }) + } + ); + } + return cloneElement(children, { + className: clsx( + inputFieldClassNames.input, + "py-8", + "file:bg-primary file:text-on-primary file:border-none file:rounded file:text-sm file:font-semibold file:px-10 file:h-24 file:mr-10" + ) + }); +} +function FormFileEntryField(props) { + const { + field: { onChange, value = null }, + fieldState: { error } + } = useController({ + name: props.name + }); + const formProps = { + onChange, + value, + invalid: error != null, + errorMessage: error ? /* @__PURE__ */ jsx(Trans, { message: "Please select a file." }) : null + }; + return /* @__PURE__ */ jsx(FileEntryField, { ...mergeProps(formProps, props) }); +} +function CrupdateCaptionDialog({ caption }) { + const { close, formId } = useDialogContext(); + const form = useForm({ + defaultValues: { + language: "en", + ...caption + } + }); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: caption ? /* @__PURE__ */ jsx(Trans, { message: "Update caption" }) : /* @__PURE__ */ jsx(Trans, { message: "Add caption" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs(Form$1, { id: formId, form, onSubmit: (newValues) => close(newValues), children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Name" }), + className: "mb-24", + required: true, + autoFocus: true + } + ), + /* @__PURE__ */ jsx(LanguageSelect$3, {}), + /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsx( + FormFileEntryField, + { + required: !caption, + name: "url", + diskPrefix: "captions", + disk: Disk.public, + allowedFileTypes: [".vtt"], + maxFileSize: 1024 * 1024, + label: /* @__PURE__ */ jsx(Trans, { message: "Caption file" }), + onChange: () => { + form.clearErrors(); + } + } + ) }) + ] }) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: () => close(), children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx(Button, { form: formId, variant: "flat", color: "primary", type: "submit", children: caption ? /* @__PURE__ */ jsx(Trans, { message: "Update" }) : /* @__PURE__ */ jsx(Trans, { message: "Add" }) }) + ] }) + ] }); +} +function LanguageSelect$3() { + var _a2; + const { trans } = useTrans(); + const { data } = useValueLists(["languages"]); + return /* @__PURE__ */ jsx( + FormSelect, + { + name: "language", + selectionMode: "single", + showSearchField: true, + searchPlaceholder: trans(message("Search languages")), + label: /* @__PURE__ */ jsx(Trans, { message: "Language" }), + className: "mb-24", + children: (_a2 = data == null ? void 0 : data.languages) == null ? void 0 : _a2.map((language) => /* @__PURE__ */ jsx(Item, { value: language.code, capitalizeFirst: true, children: /* @__PURE__ */ jsx(Trans, { message: language.name }) }, language.code)) + } + ); +} +const SubtitlesIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zM6 10h2v2H6zm0 4h8v2H6zm10 0h2v2h-2zm-6-4h8v2h-8z" }), + "SubtitlesOutlined" +); +function CaptionsPanel() { + const { watch } = useFormContext(); + const { fields, append, remove, swap, update } = useFieldArray({ + name: "captions", + keyName: "key" + }); + const sourceType = watch("type"); + const supportsCaptions = sourceType === "video"; + return /* @__PURE__ */ jsxs("div", { className: "mt-24", children: [ + /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-24", children: [ + /* @__PURE__ */ jsx("div", { className: "text-xl font-medium", children: /* @__PURE__ */ jsx(Trans, { message: "Captions" }) }), + /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (values) => { + if (values) { + append(values); + } + }, + children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + size: "xs", + disabled: !supportsCaptions, + children: /* @__PURE__ */ jsx(Trans, { message: "Add caption" }) + } + ), + /* @__PURE__ */ jsx(CrupdateCaptionDialog, {}) + ] + } + ) + ] }), + /* @__PURE__ */ jsxs("div", { className: "mt-24", children: [ + !supportsCaptions || !(fields == null ? void 0 : fields.length) ? /* @__PURE__ */ jsx( + IllustratedMessage, + { + size: "sm", + image: /* @__PURE__ */ jsx(SubtitlesIcon, {}), + imageHeight: "h-24", + imageMargin: "mb-12", + title: /* @__PURE__ */ jsx(NoCaptionsMessage, { sourceType }) + } + ) : null, + supportsCaptions && fields.map((caption, index) => /* @__PURE__ */ jsx( + CaptionItem, + { + caption, + captions: fields, + onSort: (oldIndex, newIndex) => swap(oldIndex, newIndex), + onRemove: () => remove(index), + onUpdate: (values) => update(index, values) + }, + caption.key + )) + ] }) + ] }); +} +function CaptionItem({ + caption, + captions, + onSort, + onRemove, + onUpdate +}) { + const domRef = useRef(null); + const previewRef = useRef(null); + const isTouchDevice = useIsTouchDevice(); + const { sortableProps, dragHandleRef } = useSortable({ + ref: domRef, + disabled: isTouchDevice ?? false, + item: caption, + items: captions, + type: "captionItem", + preview: previewRef, + strategy: "line", + onSortEnd: (oldIndex, newIndex) => onSort(oldIndex, newIndex) + }); + return /* @__PURE__ */ jsxs( + "div", + { + className: "mb-6 flex items-center border-b border-t border-transparent", + ref: domRef, + ...sortableProps, + children: [ + /* @__PURE__ */ jsx(IconButton, { ref: dragHandleRef, "aria-label": "Sort captions", children: /* @__PURE__ */ jsx(DragHandleIcon, {}) }), + /* @__PURE__ */ jsx("div", { className: "ml-12 capitalize", children: caption.name }), + /* @__PURE__ */ jsx("div", { className: "ml-auto mr-12 rounded border px-8 py-4 text-xs uppercase", children: caption.language }), + /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (values) => { + if (values) { + onUpdate(values); + } + }, + children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Edit" }), children: /* @__PURE__ */ jsx(IconButton, { onClick: () => onRemove(), className: "text-muted", children: /* @__PURE__ */ jsx(SettingsIcon, {}) }) }), + /* @__PURE__ */ jsx(CrupdateCaptionDialog, { caption }) + ] + } + ), + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Remove" }), children: /* @__PURE__ */ jsx(IconButton, { onClick: () => onRemove(), className: "text-danger", children: /* @__PURE__ */ jsx(CloseIcon, {}) }) }), + /* @__PURE__ */ jsx(CaptionItemDragPreview, { caption, ref: previewRef }) + ] + } + ); +} +const CaptionItemDragPreview = React.forwardRef(({ caption }, ref) => { + return /* @__PURE__ */ jsx(DragPreview, { ref, children: () => /* @__PURE__ */ jsx("div", { className: "rounded bg-background p-8 text-base shadow", children: caption.name }) }); +}); +function NoCaptionsMessage({ sourceType }) { + switch (sourceType) { + case "video": + return /* @__PURE__ */ jsx(Trans, { message: "No captions have been added to this video yet." }); + case "stream": + return /* @__PURE__ */ jsx(Trans, { message: "Captions (if available) are embedded within the stream itself." }); + default: + return /* @__PURE__ */ jsx(Trans, { message: "This source type does not support captions." }); + } +} +function CrupdateVideoForm({ form, video }) { + return /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-54", children: [ + /* @__PURE__ */ jsxs("div", { className: "flex-auto", children: [ + /* @__PURE__ */ jsx(VideoPreview, { video }), + /* @__PURE__ */ jsx(ReloadMessage, { form }), + /* @__PURE__ */ jsx(CaptionsPanel, {}) + ] }), + /* @__PURE__ */ jsx("div", { className: "w-440 flex-shrink-0", children: /* @__PURE__ */ jsx(VideoForm, {}) }) + ] }); +} +function ReloadMessage({ form }) { + const dirty = form.formState.dirtyFields; + if (!dirty.src && !dirty.thumbnail) + return null; + return /* @__PURE__ */ jsxs("div", { className: "mt-12 flex items-center gap-6 text-sm text-muted", children: [ + /* @__PURE__ */ jsx( + InfoDialogTriggerIcon, + { + size: "xs", + className: "text-muted", + viewBox: "0 0 16 16" + } + ), + /* @__PURE__ */ jsx(Trans, { message: "Save your changes to reload video preview." }) + ] }); +} +function VideoPreview({ video }) { + if (!video || !video.src) { + return /* @__PURE__ */ jsx(VideoPlayerSkeleton, { animate: false }); + } + return /* @__PURE__ */ jsx( + SiteVideoPlayer, + { + video, + mediaItemId: `${video.id}-${video.updated_at}` + } + ); +} +function VideoForm() { + return /* @__PURE__ */ jsxs(FileUploadProvider, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Name" }), + className: "mb-24", + required: true + } + ), + /* @__PURE__ */ jsx( + TitleSelect, + { + name: "title_id", + seasonName: "season_num", + episodeName: "episode_num", + className: "mb-24" + } + ), + /* @__PURE__ */ jsx( + FormImageSelector, + { + name: "thumbnail", + label: /* @__PURE__ */ jsx(Trans, { message: "Thumbnail" }), + diskPrefix: "video-thumbnails", + className: "mb-24" + } + ), + /* @__PURE__ */ jsx(SourceTypeSelect, {}), + /* @__PURE__ */ jsx(SourceField, {}), + /* @__PURE__ */ jsx(QualitySelect, {}), + /* @__PURE__ */ jsx(LanguageSelect$2, {}), + /* @__PURE__ */ jsx(ContentTypeSelect, {}) + ] }); +} +function SourceTypeSelect() { + const { setValue } = useFormContext(); + return /* @__PURE__ */ jsxs( + FormSelect, + { + name: "type", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Source type" }), + className: "mb-24", + onSelectionChange: () => setValue("src", ""), + children: [ + /* @__PURE__ */ jsx( + Item, + { + value: "embed", + description: /* @__PURE__ */ jsx(Trans, { message: "Embed video hosted on another site. Youtube, vimeo etc." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Embed" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "video", + description: /* @__PURE__ */ jsx(Trans, { message: "Upload a video file or enter a url to direct video (.mp4, .webm, .avi, .mov etc.) hosted online." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Direct" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "stream", + description: /* @__PURE__ */ jsx(Trans, { message: "Enter a url to HLS or DASH stream." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Adaptive stream" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "external", + description: /* @__PURE__ */ jsx(Trans, { message: "Enter any url. User will be redirected to this url after clicking the video." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Basic url" }) + } + ) + ] + } + ); +} +function SourceField() { + const { watch } = useFormContext(); + const isEmbed = watch("type") === "embed"; + const isUrl = watch("type") === "external"; + const canUpload = watch("type") === "video"; + const { trans } = useTrans(); + if (canUpload) { + return /* @__PURE__ */ jsx(DirectSourceField, {}); + } + return /* @__PURE__ */ jsx( + FormTextField, + { + required: true, + name: "src", + label: /* @__PURE__ */ jsx(Trans, { message: "Source" }), + className: "mb-24", + type: isUrl ? "url" : void 0, + placeholder: isEmbed ? trans(message("Full embed code snippet or just src url")) : void 0, + inputElementType: isEmbed ? "textarea" : "input", + rows: 4 + } + ); +} +function DirectSourceField() { + const form = useFormContext(); + const [type, setType] = useState(() => { + const src = form.getValues("src"); + return src.includes("api/v1/file-entries") || src.includes("storage/title-videos") ? "file" : "url"; + }); + return /* @__PURE__ */ jsxs("div", { className: "mb-24", children: [ + /* @__PURE__ */ jsxs(RadioGroup, { size: "sm", className: "mb-8", name: "direct-type", children: [ + /* @__PURE__ */ jsx( + Radio, + { + value: "url", + checked: type === "url", + onChange: (e) => setType(e.target.value), + children: /* @__PURE__ */ jsx(Trans, { message: "Url" }) + } + ), + /* @__PURE__ */ jsx( + Radio, + { + value: "file", + checked: type === "file", + onChange: (e) => setType(e.target.value), + children: /* @__PURE__ */ jsx(Trans, { message: "File" }) + } + ) + ] }), + type === "file" ? /* @__PURE__ */ jsx( + FormFileEntryField, + { + required: true, + name: "src", + disk: Disk.public, + diskPrefix: "title-videos", + label: /* @__PURE__ */ jsx(Trans, { message: "Source" }) + } + ) : /* @__PURE__ */ jsx( + FormTextField, + { + name: "src", + label: /* @__PURE__ */ jsx(Trans, { message: "source" }), + inputElementType: "textarea", + rows: 2, + required: true, + type: "url" + } + ) + ] }); +} +function QualitySelect() { + const { streaming } = useSettings(); + const qualities = (streaming == null ? void 0 : streaming.qualities) || []; + return /* @__PURE__ */ jsx( + FormSelect, + { + name: "quality", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Quality" }), + className: "mb-24", + children: qualities.map((quality) => /* @__PURE__ */ jsx(Item, { value: quality.toLowerCase(), capitalizeFirst: true, children: /* @__PURE__ */ jsx(Trans, { message: quality }) }, quality)) + } + ); +} +function LanguageSelect$2() { + var _a2, _b; + const { trans } = useTrans(); + const query = useValueLists(["languages"]); + return /* @__PURE__ */ jsx( + FormSelect, + { + name: "language", + selectionMode: "single", + showSearchField: true, + searchPlaceholder: trans(message("Search languages")), + label: /* @__PURE__ */ jsx(Trans, { message: "Language" }), + className: "mb-24", + children: (_b = (_a2 = query.data) == null ? void 0 : _a2.languages) == null ? void 0 : _b.map((language) => /* @__PURE__ */ jsx(Item, { value: language.code, capitalizeFirst: true, children: /* @__PURE__ */ jsx(Trans, { message: language.name }) }, language.code)) + } + ); +} +function ContentTypeSelect() { + return /* @__PURE__ */ jsxs( + FormSelect, + { + name: "category", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Content type" }), + className: "mb-24", + children: [ + /* @__PURE__ */ jsx(Item, { value: "trailer", children: /* @__PURE__ */ jsx(Trans, { message: "Trailer" }) }), + /* @__PURE__ */ jsx(Item, { value: "clip", children: /* @__PURE__ */ jsx(Trans, { message: "Clip" }) }), + /* @__PURE__ */ jsx(Item, { value: "featurette", children: /* @__PURE__ */ jsx(Trans, { message: "Featurette" }) }), + /* @__PURE__ */ jsx(Item, { value: "teaser", children: /* @__PURE__ */ jsx(Trans, { message: "Teaser" }) }), + /* @__PURE__ */ jsx(Item, { value: "full", children: /* @__PURE__ */ jsx(Trans, { message: "Full Movie or Episode" }) }) + ] + } + ); +} +function CreateVideoPage({ children }) { + const { titleId, season, episode } = useParams(); + const navigate = useNavigate$1(); + const form = useForm({ + defaultValues: { + quality: "regular", + language: "en", + category: "trailer", + type: "embed", + title_id: titleId ? Number(titleId) : void 0, + season_num: season ? Number(season) : void 0, + episode_num: episode ? Number(episode) : void 0 + } + }); + const createVideo2 = useCreateVideo(form); + return /* @__PURE__ */ jsxs( + CrupdateResourceLayout, + { + onSubmit: (values) => { + createVideo2.mutate(values, { + onSuccess: (response) => { + toast(message("Video created")); + if (titleId) { + navigate(`../`, { + relative: "path" + }); + } else { + navigate(`../${response.video.id}/edit`, { + relative: "path" + }); + } + } + }); + }, + backButton: titleId ? /* @__PURE__ */ jsx( + IconButton, + { + className: "text-muted", + elementType: Link, + to: "../", + relative: "path", + children: /* @__PURE__ */ jsx(ArrowBackIcon, {}) + } + ) : void 0, + form, + title: /* @__PURE__ */ jsx(Trans, { message: "New video" }), + isLoading: createVideo2.isPending, + disableSaveWhenNotDirty: true, + children: [ + children, + /* @__PURE__ */ jsx(CrupdateVideoForm, { form }) + ] + } + ); +} +function useUpdateVideo(form) { + const { videoId } = useParams(); + return useMutation({ + mutationFn: (payload) => updateVideo(videoId, payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["video"] }); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function updateVideo(videoId, payload) { + return apiClient.put(`videos/${videoId}`, payload).then((r) => r.data); +} +function useVideo() { + const { videoId } = useParams(); + return useQuery({ + queryKey: ["video", `${videoId}`], + queryFn: () => fetchVideo(videoId) + }); +} +function fetchVideo(videoId) { + return apiClient.get(`videos/${videoId}`).then((response) => response.data); +} +function EditVideoPage() { + var _a2; + const { titleId } = useParams(); + const navigate = useNavigate$1(); + const form = useForm(); + const query = useVideo(); + const video = (_a2 = query.data) == null ? void 0 : _a2.video; + const updateVideo2 = useUpdateVideo(form); + const link = video ? getWatchLink(video) : video; + useEffect(() => { + var _a3; + if (video && !form.getValues().name) { + form.reset({ + name: video.name, + title_id: video.title_id, + season_num: video.season_num, + episode_num: video.episode_num, + thumbnail: video.thumbnail, + type: video.type, + src: video.src, + quality: video.quality, + language: video.language, + category: video.category, + captions: ((_a3 = video.captions) == null ? void 0 : _a3.map((caption) => ({ + id: caption.id, + name: caption.name, + url: caption.url, + language: caption.language + }))) || [] + }); + } + }, [video, form]); + return /* @__PURE__ */ jsx( + CrupdateResourceLayout, + { + onSubmit: (values) => { + updateVideo2.mutate(values, { + onSuccess: () => { + form.reset(values); + toast(message("Video updated")); + if (titleId) { + navigate(`../../`, { + relative: "path" + }); + } + } + }); + }, + backButton: titleId ? /* @__PURE__ */ jsx( + IconButton, + { + className: "text-muted", + elementType: Link, + to: "../../", + relative: "path", + children: /* @__PURE__ */ jsx(ArrowBackIcon, {}) + } + ) : void 0, + form, + title: video ? /* @__PURE__ */ jsx(Trans, { values: { name: video.name }, message: "Edit “:name“" }) : /* @__PURE__ */ jsx(Trans, { message: "Edit video" }), + actions: link ? /* @__PURE__ */ jsx(IconButton, { size: "sm", elementType: Link, to: link, target: "_blank", children: /* @__PURE__ */ jsx(OpenInNewIcon, {}) }) : null, + isLoading: query.isLoading || updateVideo2.isPending, + disableSaveWhenNotDirty: true, + children: query.isLoading ? /* @__PURE__ */ jsx(FullPageLoader, {}) : /* @__PURE__ */ jsx(CrupdateVideoForm, { form, video }) + } + ); +} +const movieNightImage = "/assets/movie-night-f53006ef.svg"; +const TitlesDatatableColumns = [ + { + key: "name", + allowsSorting: true, + width: "flex-3", + visibleInMode: "all", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Title" }), + body: (title) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-12", children: [ + /* @__PURE__ */ jsx( + TitlePoster, + { + title, + srcSize: "sm", + size: "w-32", + aspect: "aspect-square" + } + ), + /* @__PURE__ */ jsxs("div", { className: "overflow-hidden min-w-0", children: [ + /* @__PURE__ */ jsx("div", { className: "overflow-hidden overflow-ellipsis", children: /* @__PURE__ */ jsx(TitleLink, { title, target: "_blank" }) }), + /* @__PURE__ */ jsx("div", { className: "text-muted text-xs overflow-hidden overflow-ellipsis", children: title.is_series ? /* @__PURE__ */ jsx(Trans, { message: "Series" }) : /* @__PURE__ */ jsx(Trans, { message: "Movie" }) }) + ] }) + ] }) + }, + { + key: "release_date", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Release date" }), + body: (title) => /* @__PURE__ */ jsx(FormattedDate, { date: title.release_date }) + }, + { + key: "rating", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Rating" }), + body: (title) => /* @__PURE__ */ jsx(TitleRating, { score: title.rating }), + width: "w-124 flex-shrink-0" + }, + { + key: "views", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Page views" }), + body: (title) => /* @__PURE__ */ jsx(FormattedNumber, { value: title.views }), + width: "w-124 flex-shrink-0" + }, + { + key: "popularity", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Popularity" }), + body: (title) => title.popularity ? /* @__PURE__ */ jsx(FormattedNumber, { value: title.popularity }) : null, + width: "w-124 flex-shrink-0" + }, + { + key: "updated_at", + allowsSorting: true, + width: "w-124 flex-shrink-0", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (title) => title.updated_at ? /* @__PURE__ */ jsx(FormattedDate, { date: title.updated_at }) : "" + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + visibleInMode: "all", + align: "end", + width: "w-84 flex-shrink-0", + body: (title) => /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + className: "text-muted", + elementType: Link, + to: `${title.id}/insights`, + children: /* @__PURE__ */ jsx(BarChartIcon, {}) + } + ), + /* @__PURE__ */ jsx(Link, { to: `${title.id}/edit/primary-facts`, className: "text-muted", children: /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Edit" }), children: /* @__PURE__ */ jsx(IconButton, { size: "md", children: /* @__PURE__ */ jsx(EditIcon, {}) }) }) }) + ] }) + } +]; +const TitlesDatatableFilters = [ + { + key: "is_series", + label: message("Type"), + description: message("Whether title is a movie or a TV series"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "02", + options: [ + { + key: "01", + label: message("Both"), + value: false + }, + { + key: "02", + label: message("Movie"), + value: false + }, + { + key: "03", + label: message("TV series"), + value: true + } + ] + } + }, + { + key: "views", + label: message("Page views"), + description: message("Number of unique page views"), + defaultOperator: FilterOperator.lte, + operators: ALL_PRIMITIVE_OPERATORS, + control: { + type: FilterControlType.Input, + inputType: "number", + minValue: 1, + defaultValue: 100 + } + }, + { + key: "poster", + label: message("No poster"), + description: message("Whether title has a poster"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.BooleanToggle, + defaultValue: null + } + }, + createdAtFilter({ + description: message("Date title was created") + }), + updatedAtFilter({ + description: message("Date title was last updated") + }) +]; +function useImportSingleFromTmdb() { + const { trans } = useTrans(); + return useMutation({ + mutationFn: (props) => importMediaItem(props), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("titles") + }); + toast(trans(message("Item imported"))); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function importMediaItem(payload) { + return apiClient.post("media/import", payload).then((r) => r.data); +} +function ImportSingleFromTmdbDialog({ + modelType +}) { + const form = useForm({ + defaultValues: { + media_type: modelType === TITLE_MODEL ? "movie" : "person" + } + }); + const { formId, close } = useDialogContext(); + const importItem = useImportSingleFromTmdb(); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Import from TheMovieDB" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs( + Form$1, + { + id: formId, + form, + onSubmit: (values) => { + importItem.mutate(values, { + onSuccess: (response) => { + close(response.mediaItem); + } + }); + }, + children: [ + modelType === TITLE_MODEL && /* @__PURE__ */ jsxs( + FormSelect, + { + name: "media_type", + label: /* @__PURE__ */ jsx(Trans, { message: "Type" }), + className: "mb-24", + selectionMode: "single", + children: [ + /* @__PURE__ */ jsx(Item, { value: "movie", children: /* @__PURE__ */ jsx(Trans, { message: "Movie" }) }), + /* @__PURE__ */ jsx(Item, { value: "series", children: /* @__PURE__ */ jsx(Trans, { message: "Series" }) }) + ] + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + autoFocus: true, + required: true, + name: "tmdb_id", + min: 1, + type: "number", + label: /* @__PURE__ */ jsx(Trans, { message: "TheMovieDB ID" }) + } + ) + ] + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: () => close(), children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx( + Button, + { + form: formId, + variant: "flat", + color: "primary", + type: "submit", + disabled: importItem.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Import" }) + } + ) + ] }) + ] }); +} +function useImportMultipleFromTmdb() { + const { trans } = useTrans(); + const titlesList = useRef([]); + const [isLoading, setIsLoading] = useState(false); + const controller = useRef(new AbortController()); + const cancel = useCallback(() => { + controller.current.abort("canceled"); + }, []); + const handler = useCallback( + async (v, options) => { + var _a2, _b, _c; + let stopped = false; + let error = false; + let pagesToImport = v.pages_to_import ? +v.pages_to_import : 1; + const startFromPage = v.start_from_page ? +v.start_from_page : 1; + if (pagesToImport + startFromPage > 500) { + pagesToImport = 500 - startFromPage; + } + const stopImporting = () => { + setIsLoading(false); + titlesList.current = []; + controller.current = new AbortController(); + stopped = true; + }; + let currentPage = startFromPage; + setIsLoading(true); + controller.current.signal.addEventListener( + "abort", + () => stopImporting() + ); + let index = 0; + while (index <= pagesToImport && !stopped) { + if (index === 0) { + (_a2 = options.onProgress) == null ? void 0 : _a2.call(options, { + totalItems: pagesToImport * 20, + currentItem: 0, + progress: 0, + titleList: [] + }); + } + index++; + currentPage++; + try { + const response = await apiClient.post( + "tmdb/import", + formValueToPayload({ ...v, current_page: currentPage }), + { + signal: controller.current.signal + } + ).then((r) => r.data); + if (response.total_pages < pagesToImport) { + pagesToImport = response.total_pages; + } + if (titlesList.current.length > 1e3) { + titlesList.current = titlesList.current.slice(0, 1e3); + } + titlesList.current.unshift(...response.titles.map((t) => t.name)); + const totalItems = pagesToImport * 20; + const currentItem = (index - 1) * 20; + (_b = options.onProgress) == null ? void 0 : _b.call(options, { + totalItems, + currentItem, + progress: Math.round(currentItem / totalItems * 100), + titleList: titlesList.current + }); + } catch (e) { + stopImporting(); + error = true; + if (e.message !== "canceled") { + console.error(e); + showHttpErrorToast(e); + } + } + } + if (!error) { + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("titles") + }); + toast(trans(message("Titles imported"))); + setIsLoading(false); + (_c = options.onSuccess) == null ? void 0 : _c.call(options); + } + }, + [trans] + ); + return { + mutate: handler, + cancel, + isLoading + }; +} +function formValueToPayload(values) { + const payload = { + type: values.type, + pages_to_import: values.pages_to_import, + start_from_page: values.start_from_page, + current_page: values.current_page + }; + if (values.country) { + payload.country = values.country; + } + if (values.language) { + payload.language = values.language; + } + if (values.min_rating) { + payload.min_rating = values.min_rating; + } + if (values.max_rating) { + payload.max_rating = values.max_rating; + } + if (values.genres) { + payload.genres = values.genres.map((genre) => genre.id).join(","); + } + if (values.keywords) { + payload.keywords = values.keywords.map((keyword) => keyword.id).join(","); + } + if (values.release_date) { + payload.start_date = values.release_date.start; + payload.end_date = values.release_date.start; + } + return payload; +} +function ImportMultipleFromTmdbDialog() { + const form = useForm({ + defaultValues: { + type: "movie", + start_from_page: 1, + pages_to_import: 10 + } + }); + const { formId, close } = useDialogContext(); + const importTitles = useImportMultipleFromTmdb(); + const [activePanel, setActivePanel] = useState("form"); + const [progressData, setProgressData] = useState(); + return /* @__PURE__ */ jsxs(Dialog, { size: "lg", children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Import from TheMovieDB" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsx( + Form$1, + { + id: formId, + form, + onSubmit: (values) => { + importTitles.mutate(values, { + onProgress: (data) => { + setActivePanel("progress"); + setProgressData(data); + } + }); + }, + children: progressData ? /* @__PURE__ */ jsx(ProgressPanel, { data: progressData }) : /* @__PURE__ */ jsx(FormPanel, {}) + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx( + Button, + { + onClick: () => { + if (progressData) { + importTitles.cancel(); + setProgressData(void 0); + setActivePanel("form"); + } else { + close(); + } + }, + children: importTitles.isLoading || activePanel === "form" ? /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) : /* @__PURE__ */ jsx(Trans, { message: "Back" }) + } + ), + /* @__PURE__ */ jsx( + Button, + { + form: formId, + variant: "flat", + color: "primary", + type: "submit", + disabled: importTitles.isLoading || activePanel === "progress", + children: /* @__PURE__ */ jsx(Trans, { message: "Import" }) + } + ) + ] }) + ] }); +} +function FormPanel() { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs("p", { className: "mb-24 flex items-center gap-8 text-muted", children: [ + /* @__PURE__ */ jsx(InfoDialogTriggerIcon, { size: "xs", viewBox: "0 0 16 16" }), + /* @__PURE__ */ jsx(Trans, { message: "All filters below are optional and can be left empty." }) + ] }), + /* @__PURE__ */ jsxs( + FormSelect, + { + name: "type", + label: /* @__PURE__ */ jsx(Trans, { message: "Type" }), + className: "mb-24", + selectionMode: "single", + children: [ + /* @__PURE__ */ jsx(Item, { value: "movie", children: /* @__PURE__ */ jsx(Trans, { message: "Movie" }) }), + /* @__PURE__ */ jsx(Item, { value: "series", children: /* @__PURE__ */ jsx(Trans, { message: "Series" }) }) + ] + } + ), + /* @__PURE__ */ jsx(PaginationFields, {}), + /* @__PURE__ */ jsx(GenreChipField, {}), + /* @__PURE__ */ jsx(KeywordChipField, {}), + /* @__PURE__ */ jsx(LanguageSelect$1, {}), + /* @__PURE__ */ jsx(CountrySelect, {}), + /* @__PURE__ */ jsx(RatingFields, {}), + /* @__PURE__ */ jsx( + FormDateRangePicker, + { + name: "release_date", + granularity: "day", + label: /* @__PURE__ */ jsx(Trans, { message: "Release date" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Only import titles released between specified dates." }) + } + ) + ] }); +} +function ProgressPanel({ data }) { + return /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx( + ProgressBar, + { + value: data.progress, + label: /* @__PURE__ */ jsx( + Trans, + { + message: "Imported :number titles of :total", + values: { number: data.currentItem, total: data.totalItems } + } + ) + } + ), + /* @__PURE__ */ jsxs("div", { className: "compact-scrollbar mt-24 h-400 overflow-auto text-xs", children: [ + data.titleList.map((title, index) => /* @__PURE__ */ jsx("div", { children: title }, index)), + !data.titleList.length ? /* @__PURE__ */ jsx(Fragment, { children: [...new Array(20).keys()].map((index) => /* @__PURE__ */ jsx(Skeleton, { className: "mb-2 max-w-200" }, index)) }) : null + ] }) + ] }); +} +function PaginationFields() { + return /* @__PURE__ */ jsxs("div", { className: "mb-24", children: [ + /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-24", children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + className: "flex-1", + name: "start_from_page", + label: /* @__PURE__ */ jsx(Trans, { message: "Starting page" }), + type: "number", + min: 1, + max: 500 + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + className: "flex-1", + name: "pages_to_import", + label: /* @__PURE__ */ jsx(Trans, { message: "How many pages to import" }), + type: "number", + min: 1, + max: 500 + } + ) + ] }), + /* @__PURE__ */ jsx("p", { className: "pt-10 text-xs text-muted", children: /* @__PURE__ */ jsx(Trans, { message: "20 titles per page are imported. " }) }) + ] }); +} +function GenreChipField() { + const { data } = useFilterValueLists(); + const genres = data == null ? void 0 : data.genres.map((genre) => ({ + id: genre.value, + name: genre.name + })); + return /* @__PURE__ */ jsx( + FormChipField, + { + className: "mb-24", + name: "genres", + label: /* @__PURE__ */ jsx(Trans, { message: "Genres" }), + suggestions: genres, + allowCustomValue: false, + description: /* @__PURE__ */ jsx(Trans, { message: "Only import titles belonging to specified genres." }), + children: (genre) => /* @__PURE__ */ jsx(Item, { value: genre.id, children: /* @__PURE__ */ jsx(Trans, { message: genre.name }) }) + } + ); +} +function KeywordChipField() { + const { data } = useFilterValueLists(); + const keywords = data == null ? void 0 : data.keywords.map((keyword) => ({ + id: keyword.value, + name: keyword.name + })); + return /* @__PURE__ */ jsx( + FormChipField, + { + name: "keywords", + className: "mb-24", + label: /* @__PURE__ */ jsx(Trans, { message: "Keywords" }), + suggestions: keywords, + allowCustomValue: false, + description: /* @__PURE__ */ jsx(Trans, { message: "Only import titles that have specied keywords attached." }), + children: (keyword) => /* @__PURE__ */ jsx(Item, { value: keyword.id, children: /* @__PURE__ */ jsx(Trans, { message: keyword.name }) }) + } + ); +} +function LanguageSelect$1() { + const { data } = useFilterValueLists(); + return /* @__PURE__ */ jsx( + FormSelect, + { + name: "language", + className: "mb-24", + label: /* @__PURE__ */ jsx(Trans, { message: "Language" }), + items: data == null ? void 0 : data.languages, + selectionMode: "single", + description: /* @__PURE__ */ jsx(Trans, { message: "Only import titles with specied primary spoken language." }), + children: (language) => /* @__PURE__ */ jsx(Item, { value: language.code, children: /* @__PURE__ */ jsx(Trans, { message: language.name }) }) + } + ); +} +function CountrySelect() { + const { data } = useFilterValueLists(); + return /* @__PURE__ */ jsx( + FormSelect, + { + name: "country", + className: "mb-24", + label: /* @__PURE__ */ jsx(Trans, { message: "Country" }), + items: data == null ? void 0 : data.countries, + selectionMode: "single", + description: /* @__PURE__ */ jsx(Trans, { message: "Only import titles with specied origin country." }), + children: (country) => /* @__PURE__ */ jsx(Item, { value: country.code, children: /* @__PURE__ */ jsx(Trans, { message: country.name }) }) + } + ); +} +function RatingFields() { + return /* @__PURE__ */ jsxs("div", { className: "mb-24 flex items-center gap-24", children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + className: "flex-1", + name: "min_rating", + label: /* @__PURE__ */ jsx(Trans, { message: "Minimum rating" }), + type: "number", + min: 1, + max: 10 + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + className: "flex-1", + name: "max_rating", + label: /* @__PURE__ */ jsx(Trans, { message: "Maximum rating" }), + type: "number", + min: 1, + max: 10 + } + ) + ] }); +} +function useFilterValueLists() { + return useValueLists(["genres", "keywords", "languages", "countries"], { + type: "tmdb" + }); +} +function TitlesDatatablePage() { + const { filters, filtersLoading } = useTitleIndexFilters(); + const mergedFilters = useMemo(() => { + return [...filters, ...TitlesDatatableFilters]; + }, [filters]); + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "titles", + title: /* @__PURE__ */ jsx(Trans, { message: "Titles" }), + columns: TitlesDatatableColumns, + filters: mergedFilters, + filtersLoading, + actions: /* @__PURE__ */ jsx(Actions$6, {}), + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: movieNightImage, + title: /* @__PURE__ */ jsx(Trans, { message: "No titles have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching titles" }) + } + ) + } + ); +} +function Actions$6() { + const { tmdb_is_setup } = useSettings(); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + tmdb_is_setup && /* @__PURE__ */ jsx(ImportButton, {}), + /* @__PURE__ */ jsx(DataTableAddItemButton, { elementType: Link, to: "new", children: /* @__PURE__ */ jsx(Trans, { message: "Add title" }) }) + ] }); +} +function ImportButton() { + const navigate = useNavigate$1(); + return /* @__PURE__ */ jsxs(MenuTrigger, { children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Import from TheMovieDB" }), children: /* @__PURE__ */ jsx( + IconButton, + { + variant: "outline", + color: "primary", + className: "flex-shrink-0", + size: "sm", + children: /* @__PURE__ */ jsx(PublishIcon, {}) + } + ) }), + /* @__PURE__ */ jsxs(Menu, { children: [ + /* @__PURE__ */ jsx( + Item, + { + value: "single", + onSelected: async () => { + const title = await openDialog(ImportSingleFromTmdbDialog, { + modelType: TITLE_MODEL + }); + if (title) { + navigate(`/admin/titles/${title.id}/edit/primary-facts`); + } + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Import single title by ID" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "multiple", + onSelected: () => { + openDialog(ImportMultipleFromTmdbDialog); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Import multiple titles" }) + } + ) + ] }) + ] }); +} +function EditTitlePage() { + const query = useTitle("editTitlePage"); + if (!query.data) { + return /* @__PURE__ */ jsx(FullPageLoader, {}); + } + return /* @__PURE__ */ jsx(Outlet, { context: query.data.title }); +} +function useDeleteEpisode(episode) { + return useMutation({ + mutationFn: () => deleteEpisode(episode.id), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: seasonQueryKey(episode.title_id, episode.season_number) + }); + toast(message("Episode deleted")); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function deleteEpisode(seasonId) { + return apiClient.delete(`episodes/${seasonId}`).then((r) => r.data); +} +const allMenuItems = [ + { to: "primary-facts", label: message("Primary Facts") }, + { to: "seasons", label: message("Seasons"), hideIfMovie: true }, + { to: "images", label: message("Images") }, + { to: "videos", label: message("Videos") }, + { to: "cast", label: message("Cast") }, + { to: "crew", label: message("Crew") }, + { to: "genres", label: message("Genres") }, + { to: "keywords", label: message("Keywords") }, + { to: "countries", label: message("Countries") }, + { to: "reviews", label: message("Reviews") }, + { to: "comments", label: message("Comments") } +]; +function useFilteredMenuItems() { + const title = useOutletContext(); + const isMovie = !(title == null ? void 0 : title.is_series); + return allMenuItems.filter((item) => !isMovie || !item.hideIfMovie); +} +function TitleEditorLayout({ children, actions }) { + const isMobile = useIsMobileMediaQuery(); + const { isSticky, sentinelRef } = useStickySentinel(); + const title = useOutletContext(); + const { season, episode } = useParams(); + const link = title ? getTitleLink(title, { season, episode }) : null; + const ref = useRef(null); + const heading = title ? /* @__PURE__ */ jsx(Trans, { values: { name: title.name }, message: "Edit “:name“" }) : /* @__PURE__ */ jsx(Trans, { message: "New title" }); + useScrollToTop(ref); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(StaticPageTitle, { children: /* @__PURE__ */ jsx(Trans, { message: "Edit title" }) }), + /* @__PURE__ */ jsx("div", { ref: sentinelRef }), + /* @__PURE__ */ jsx( + "div", + { + ref, + className: clsx( + "sticky top-0 my-12 md:my-24 z-10 transition-shadow", + isSticky && "bg-paper shadow" + ), + children: /* @__PURE__ */ jsxs( + "div", + { + className: clsx( + "flex items-center md:items-start gap-24 py-14 container mx-auto px-24" + ), + children: [ + /* @__PURE__ */ jsx("h1", { className: "text-xl md:text-3xl whitespace-nowrap overflow-hidden overflow-ellipsis md:mr-64", children: heading }), + /* @__PURE__ */ jsx("div", { className: "mr-auto" }), + link ? /* @__PURE__ */ jsx(IconButton, { size: "sm", elementType: Link, to: link, target: "_blank", children: /* @__PURE__ */ jsx(OpenInNewIcon, {}) }) : null, + actions + ] + } + ) + } + ), + /* @__PURE__ */ jsxs("div", { className: "container md:flex gap-30 items-stretch mx-auto px-24 pb-24", children: [ + isMobile ? /* @__PURE__ */ jsx(MobileNav, {}) : /* @__PURE__ */ jsx(DesktopNav, {}), + /* @__PURE__ */ jsx("div", { className: "md:pl-30 flex-auto relative", children }) + ] }) + ] }); +} +function MobileNav() { + const { titleId } = useParams(); + const { pathname } = useLocation(); + const navigate = useNavigate(); + const value = titleId ? pathname.split("/").pop() : "primary-facts"; + const menuItems = useFilteredMenuItems(); + return /* @__PURE__ */ jsx( + SelectForwardRef, + { + disabled: !titleId, + minWidth: "min-w-none", + className: "w-full bg-paper mb-24", + selectionMode: "single", + selectedValue: value, + onSelectionChange: (newPage) => { + if (titleId) { + navigate(itemLink(titleId, newPage)); + } + }, + children: menuItems.map((item) => /* @__PURE__ */ jsx(Item, { value: item.to, children: /* @__PURE__ */ jsx(Trans, { ...item.label }) }, item.to)) + } + ); +} +function DesktopNav() { + const { titleId } = useParams(); + const menuItems = useFilteredMenuItems(); + return /* @__PURE__ */ jsxs("div", { className: "w-240 sticky top-24 flex-shrink-0", children: [ + menuItems.map((item) => { + const link = titleId ? itemLink(titleId, item.to) : ""; + return /* @__PURE__ */ jsx( + NavLink, + { + to: link, + "aria-disabled": !titleId, + className: ({ isActive }) => clsx( + "block p-14 whitespace-nowrap mb-8 rounded border-l-4 text-sm transition-bg-color", + !link && "pointer-events-none text-muted", + isActive && link || item.to === "primary-facts" && !link ? "bg-primary/selected border-l-primary font-medium" : "border-l-transparent hover:bg-hover" + ), + children: /* @__PURE__ */ jsx(Trans, { ...item.label }) + }, + item.to + ); + }), + !titleId ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-8 text-muted text-xs mt-24", children: [ + /* @__PURE__ */ jsx(InfoDialogTriggerIcon, { viewBox: "0 0 16 16", size: "xs" }), + /* @__PURE__ */ jsx(Trans, { message: "Create title to enable menu items." }) + ] }) : null + ] }); +} +const itemLink = (titleId, to) => `/admin/titles/${titleId}/edit/${to}`; +const PageTabs$1 = [ + { uri: "episodes", label: message("Episodes") }, + { uri: "cast", label: message("Regular cast") }, + { uri: "crew", label: message("Regular crew") } +]; +function SeasonEditorLayout({ children }) { + const { season: seasonNumber } = useParams(); + const { pathname } = useLocation(); + const tabName = pathname.split("/").pop(); + const selectedTab = seasonNumber ? PageTabs$1.findIndex((tab) => tab.uri === tabName) : 0; + return /* @__PURE__ */ jsxs(TitleEditorLayout, { children: [ + /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-12 mb-4", children: [ + /* @__PURE__ */ jsx( + IconButton, + { + elementType: Link, + to: "../../", + relative: "path", + className: "text-muted", + children: /* @__PURE__ */ jsx(ArrowBackIcon, {}) + } + ), + /* @__PURE__ */ jsx("h2", { className: "text-base", children: /* @__PURE__ */ jsx(Trans, { message: "Season :number", values: { number: seasonNumber } }) }) + ] }), + /* @__PURE__ */ jsxs(Tabs, { selectedTab, children: [ + /* @__PURE__ */ jsx(TabList, { children: PageTabs$1.map((tab) => /* @__PURE__ */ jsx( + Tab, + { + isDisabled: !seasonNumber && tab.uri !== PageTabs$1[0].uri, + width: "min-w-132", + elementType: Link, + to: `../${tab.uri}`, + relative: "path", + replace: true, + children: /* @__PURE__ */ jsx(Trans, { ...tab.label }) + }, + tab.uri + )) }), + /* @__PURE__ */ jsx("div", { className: "pt-24 min-h-512", children }) + ] }) + ] }); +} +function TitleEditorPageStatus({ query }) { + if (query.isLoading) { + return /* @__PURE__ */ jsx("div", { className: "h-full min-h-120 flex items-center justify-center", children: /* @__PURE__ */ jsx(ProgressCircle, { isIndeterminate: true, "aria-label": "Loading page..." }) }); + } + return /* @__PURE__ */ jsx(PageErrorMessage, {}); +} +function SeasonEditorEpisodeList() { + return /* @__PURE__ */ jsxs(SeasonEditorLayout, { children: [ + /* @__PURE__ */ jsx("div", { className: "mb-16", children: /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "primary", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + size: "xs", + elementType: Link, + to: "new", + children: /* @__PURE__ */ jsx(Trans, { message: "Add episode" }) + } + ) }), + /* @__PURE__ */ jsx(Content, {}) + ] }); +} +function Content() { + var _a2; + const query = useSeason("editSeasonPage"); + if (query.data) { + return ((_a2 = query.data.episodes) == null ? void 0 : _a2.data.length) ? /* @__PURE__ */ jsx(LazyEpisodeList, { data: query.data }) : /* @__PURE__ */ jsx(NoEpisodesMessage, {}); + } else { + return /* @__PURE__ */ jsx(TitleEditorPageStatus, { query }); + } +} +function NoEpisodesMessage() { + return /* @__PURE__ */ jsx( + IllustratedMessage, + { + className: "mt-40", + imageMargin: "mb-8", + image: /* @__PURE__ */ jsx("div", { className: "text-muted", children: /* @__PURE__ */ jsx(TvIcon, { size: "xl" }) }), + imageHeight: "h-auto", + title: /* @__PURE__ */ jsx(Trans, { message: "No episodes have been added yet" }) + } + ); +} +function LazyEpisodeList({ data }) { + const query = useSeasonEpisodes(data.episodes); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + query.items.map((episode) => /* @__PURE__ */ jsx( + EpisodeListItem, + { + episode, + title: data.title, + className: "mb-24", + children: /* @__PURE__ */ jsxs("div", { className: "mt-12 flex items-center gap-12", children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + size: "xs", + startIcon: /* @__PURE__ */ jsx(EditIcon, {}), + elementType: Link, + to: `${episode.episode_number}/primary-facts`, + children: /* @__PURE__ */ jsx(Trans, { message: "Edit" }) + } + ), + /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(IconButton, { size: "xs", variant: "outline", children: /* @__PURE__ */ jsx(DeleteIcon, {}) }), + /* @__PURE__ */ jsx(DeleteEpisodeDialog, { episode }) + ] }) + ] }) + }, + episode.id + )), + /* @__PURE__ */ jsx(InfiniteScrollSentinel, { query }) + ] }); +} +function DeleteEpisodeDialog({ episode }) { + const deleteEpisode2 = useDeleteEpisode(episode); + const { close } = useDialogContext(); + return /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isLoading: deleteEpisode2.isPending, + isDanger: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Delete episode" }), + body: /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete this episode?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }), + onConfirm: () => { + deleteEpisode2.mutate(void 0, { onSuccess: () => close() }); + } + } + ); +} +function useDeleteSeason(title, seasonId) { + return useMutation({ + mutationFn: () => deleteSeason(seasonId), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: titleSeasonsQueryKey(title.id) + }); + toast(message("Season deleted")); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function deleteSeason(seasonId) { + return apiClient.delete(`seasons/${seasonId}`).then((r) => r.data); +} +function useCreateSeason(titleId) { + return useMutation({ + mutationFn: () => createSeason(titleId), + onSuccess: async (response) => { + await queryClient.invalidateQueries({ + queryKey: titleSeasonsQueryKey(response.season.title_id) + }); + toast( + message("Season :number created", { + values: { number: response.season.number } + }) + ); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function createSeason(titleId) { + return apiClient.post(`titles/${titleId}/seasons`).then((r) => r.data); +} +function TitleSeasonsEditor() { + const title = useOutletContext(); + const createSeason2 = useCreateSeason(title.id); + const query = useTitleSeasons(title.id, void 0, { + perPage: 15 + }); + let content; + if (query.data) { + content = query.items.length ? /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx("div", { className: "mt-24 grid grid-cols-2 gap-24 md:grid-cols-5", children: query.items.map((season) => /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx( + SeasonPoster, + { + title, + season, + srcSize: "md", + className: "aspect-poster flex-shrink-0" + } + ), + /* @__PURE__ */ jsxs("div", { className: "mt-8", children: [ + /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-14", children: [ + /* @__PURE__ */ jsx(SeasonLink, { title, seasonNumber: season.number }), + /* @__PURE__ */ jsx("div", { className: "text-xs text-muted", children: /* @__PURE__ */ jsx( + FormattedDate, + { + date: season.release_date, + options: { year: "numeric" } + } + ) }) + ] }), + /* @__PURE__ */ jsx("div", { className: "mt-2 text-sm", children: /* @__PURE__ */ jsx( + Trans, + { + message: ":count episodes", + values: { count: season.episodes_count } + } + ) }), + /* @__PURE__ */ jsxs("div", { className: "mt-14 flex items-center justify-between gap-14", children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + size: "xs", + startIcon: /* @__PURE__ */ jsx(EditIcon, {}), + elementType: Link, + to: `${season.number}/episodes`, + children: /* @__PURE__ */ jsx(Trans, { message: "Edit" }) + } + ), + /* @__PURE__ */ jsx(DeleteButton$4, { title, season }) + ] }) + ] }) + ] }, season.id)) }), + /* @__PURE__ */ jsx(InfiniteScrollSentinel, { query }) + ] }) : /* @__PURE__ */ jsx(NoSeasonsMessage, {}); + } else { + content = /* @__PURE__ */ jsx(TitleEditorPageStatus, { query }); + } + return /* @__PURE__ */ jsxs(TitleEditorLayout, { children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "primary", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + disabled: createSeason2.isPending, + onClick: () => createSeason2.mutate(), + children: /* @__PURE__ */ jsx(Trans, { message: "Add season" }) + } + ), + content + ] }); +} +function NoSeasonsMessage() { + return /* @__PURE__ */ jsx( + IllustratedMessage, + { + className: "mt-40", + imageMargin: "mb-8", + image: /* @__PURE__ */ jsx("div", { className: "text-muted", children: /* @__PURE__ */ jsx(TvIcon, { size: "xl" }) }), + imageHeight: "h-auto", + title: /* @__PURE__ */ jsx(Trans, { message: "No seasons have been added yet" }) + } + ); +} +function DeleteButton$4({ title, season }) { + const deleteSeason2 = useDeleteSeason(title, season.id); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (confirmed) => { + if (confirmed) { + deleteSeason2.mutate(); + } + }, + children: [ + /* @__PURE__ */ jsx(IconButton, { size: "xs", variant: "outline", children: /* @__PURE__ */ jsx(DeleteIcon, {}) }), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isDanger: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Delete season" }), + body: /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete this season?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ) + ] + } + ); +} +function useCreateTitle(form) { + return useMutation({ + mutationFn: (payload) => createTitle(payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["titles"] }); + }, + onError: (r) => form ? onFormQueryError(r, form) : showHttpErrorToast(r) + }); +} +function createTitle(payload) { + return apiClient.post(`titles`, payload).then((r) => r.data); +} +function useUpdateTitle(form) { + const { titleId } = useParams(); + return useMutation({ + mutationFn: (payload) => updateTitle$1(titleId, payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["titles"] }); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function updateTitle$1(titleId, payload) { + return apiClient.put(`titles/${titleId}`, payload).then((r) => r.data); +} +function FormComboBox({ children, ...props }) { + const { + field: { onChange, onBlur, value = "", ref }, + fieldState: { invalid, error } + } = useController({ + name: props.name + }); + const formProps = { + onSelectionChange: onChange, + onBlur, + selectedValue: value, + defaultInputValue: value, + invalid, + errorMessage: error == null ? void 0 : error.message + }; + return /* @__PURE__ */ jsx(ComboBoxForwardRef, { ref, ...mergeProps(formProps, props), children }); +} +function TitlePrimaryFactsForm() { + const title = useOutletContext(); + return /* @__PURE__ */ jsx(FileUploadProvider, { children: title ? /* @__PURE__ */ jsx(EditTitleForm, { title }) : /* @__PURE__ */ jsx(CreateTitleForm, {}) }); +} +function CreateTitleForm() { + const now = useCurrentDateTime(); + const navigate = useNavigate$1(); + const form = useForm({ + defaultValues: { + release_date: now.toAbsoluteString(), + certification: "pg", + language: "en" + } + }); + const createTitle2 = useCreateTitle(form); + const isDirty = Object.keys(form.formState.dirtyFields).length > 0; + return /* @__PURE__ */ jsx( + Form$1, + { + form, + onSubmit: (values) => { + createTitle2.mutate(values, { + onSuccess: (response) => { + toast(message("Title created")); + navigate(`../${response.title.id}/edit`, { + relative: "path", + replace: true + }); + } + }); + }, + children: /* @__PURE__ */ jsx( + TitleEditorLayout, + { + actions: /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + type: "submit", + disabled: createTitle2.isPending || !isDirty, + children: /* @__PURE__ */ jsx(Trans, { message: "Create" }) + } + ), + children: /* @__PURE__ */ jsx(FormFields$1, {}) + } + ) + } + ); +} +function EditTitleForm({ title }) { + const navigate = useNavigate$1(); + const form = useForm({ + defaultValues: { + name: title.name, + is_series: title.is_series, + original_title: title.original_title, + poster: title.poster, + backdrop: title.backdrop, + release_date: title.release_date, + tagline: title.tagline, + description: title.description, + runtime: title.runtime, + certification: title.certification, + budget: title.budget, + revenue: title.revenue, + language: title.language, + popularity: title.popularity + } + }); + const updateTitle2 = useUpdateTitle(form); + return /* @__PURE__ */ jsx( + Form$1, + { + form, + onSubmit: (values) => { + updateTitle2.mutate(values, { + onSuccess: () => { + toast(message("Title updated")); + navigate("../../../", { relative: "path", replace: true }); + } + }); + }, + children: /* @__PURE__ */ jsx( + TitleEditorLayout, + { + actions: /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + type: "submit", + disabled: updateTitle2.isPending || !form.formState.isDirty, + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ), + children: /* @__PURE__ */ jsx(FormFields$1, {}) + } + ) + } + ); +} +function FormFields$1() { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs("div", { className: "gap-24 md:flex", children: [ + /* @__PURE__ */ jsx( + FormImageSelector, + { + variant: "square", + previewSize: "w-204 aspect-poster", + name: "poster", + diskPrefix: "title-posters", + label: /* @__PURE__ */ jsx(Trans, { message: "Poster" }), + showRemoveButton: true + } + ), + /* @__PURE__ */ jsxs("div", { className: "flex-auto max-md:mt-24", children: [ + /* @__PURE__ */ jsx( + FormImageSelector, + { + name: "backdrop", + variant: "square", + diskPrefix: "title-backdrops", + label: /* @__PURE__ */ jsx(Trans, { message: "Backdrop" }), + stretchPreview: true, + previewSize: "min-h-124", + className: "mb-24" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Title" }), + className: "mb-24", + required: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "original_title", + label: /* @__PURE__ */ jsx(Trans, { message: "Original title" }), + className: "mb-24" + } + ), + /* @__PURE__ */ jsx(FormSwitch, { name: "is_series", className: "mb-24", children: /* @__PURE__ */ jsx(Trans, { message: "Series" }) }) + ] }) + ] }), + /* @__PURE__ */ jsx( + FormDatePicker, + { + name: "release_date", + label: /* @__PURE__ */ jsx(Trans, { message: "Release date" }), + className: "mb-24", + granularity: "day" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "tagline", + label: /* @__PURE__ */ jsx(Trans, { message: "Tagline" }), + className: "mb-24" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "description", + label: /* @__PURE__ */ jsx(Trans, { message: "Overview" }), + inputElementType: "textarea", + rows: 4, + className: "mb-24" + } + ), + /* @__PURE__ */ jsxs("div", { className: "mb-24 items-center gap-24 md:flex", children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "runtime", + label: /* @__PURE__ */ jsx(Trans, { message: "Runtime" }), + type: "number", + min: 1, + className: "flex-1 max-md:mb-24" + } + ), + /* @__PURE__ */ jsx(CertificationCombobox, {}) + ] }), + /* @__PURE__ */ jsxs("div", { className: "mb-24 items-center gap-24 md:flex", children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "budget", + label: /* @__PURE__ */ jsx(Trans, { message: "Budget (US dollars)" }), + type: "number", + min: 1, + className: "flex-1 max-md:mb-24" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "revenue", + label: /* @__PURE__ */ jsx(Trans, { message: "Revenue (US dollars)" }), + type: "number", + min: 1, + className: "flex-1 max-md:mb-24" + } + ) + ] }), + /* @__PURE__ */ jsxs("div", { className: "mb-24 items-center gap-24 md:flex", children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "popularity", + label: /* @__PURE__ */ jsx(Trans, { message: "Popularity" }), + type: "number", + min: 1, + className: "flex-1 max-md:mb-24" + } + ), + /* @__PURE__ */ jsx(LanguageSelect, {}) + ] }) + ] }); +} +function CertificationCombobox() { + const { data } = useValueLists(["titleFilterAgeRatings"]); + return /* @__PURE__ */ jsx( + FormComboBox, + { + name: "certification", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Certification" }), + className: "flex-1", + allowCustomValue: true, + children: data == null ? void 0 : data.titleFilterAgeRatings.map(({ name, value }) => /* @__PURE__ */ jsx(Item, { value, children: /* @__PURE__ */ jsx(Trans, { message: name }) }, value)) + } + ); +} +function LanguageSelect() { + const { data } = useValueLists(["tmdbLanguages"]); + return /* @__PURE__ */ jsx( + FormSelect, + { + name: "language", + selectionMode: "single", + label: /* @__PURE__ */ jsx(Trans, { message: "Language" }), + showSearchField: true, + searchPlaceholder: "Search languages", + className: "flex-1", + children: data == null ? void 0 : data.tmdbLanguages.map((language) => /* @__PURE__ */ jsx(Item, { value: language.code, children: /* @__PURE__ */ jsx(Trans, { message: language.name }) }, language.code)) + } + ); +} +function TitleReviewsEditor() { + const title = useOutletContext(); + return /* @__PURE__ */ jsx(TitleEditorLayout, { children: /* @__PURE__ */ jsx(ReviewsDatatablePage, { hideTitle: true, reviewable: title }) }); +} +const ZoomOutMapIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "m15 3 2.3 2.3-2.89 2.87 1.42 1.42L18.7 6.7 21 9V3h-6zM3 9l2.3-2.3 2.87 2.89 1.42-1.42L6.7 5.3 9 3H3v6zm6 12-2.3-2.3 2.89-2.87-1.42-1.42L5.3 17.3 3 15v6h6zm12-6-2.3 2.3-2.87-2.89-1.42 1.42 2.89 2.87L15 21h6v-6z" }), + "ZoomOutMapOutlined" +); +function useDeleteImage(imageId) { + const { titleId } = useParams(); + return useMutation({ + mutationFn: () => deleteImage(imageId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["titles", `${titleId}`] }); + toast(message("Image deleted")); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function deleteImage(imageId) { + return apiClient.delete(`images/${imageId}`).then((r) => r.data); +} +function useUploadImage() { + const { titleId } = useParams(); + return useMutation({ + mutationFn: (payload) => uploadImage(payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["titles", `${titleId}`] }); + toast(message("Image uploaded")); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function uploadImage(payload) { + const formData = new FormData(); + formData.append("titleId", payload.titleId.toString()); + formData.append("file", payload.file); + return apiClient.post(`images`, formData).then((r) => r.data); +} +function TitleImagesEditor() { + const title = useOutletContext(); + return /* @__PURE__ */ jsxs(TitleEditorLayout, { children: [ + /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsx(UploadButton, {}) }), + /* @__PURE__ */ jsx("div", { className: "mt-24 grid grid-cols-2 gap-24 md:grid-cols-3", children: title.images.map((image, index) => /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx(TitleBackdrop, { src: image.url, srcSize: "md", className: "rounded" }), + /* @__PURE__ */ jsxs("div", { className: "mt-6 flex items-center justify-between gap-14", children: [ + /* @__PURE__ */ jsx(DeleteButton$3, { imageId: image.id }), + /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(IconButton, { variant: "outline", size: "xs", children: /* @__PURE__ */ jsx(ZoomOutMapIcon, {}) }), + /* @__PURE__ */ jsx( + ImageZoomDialog, + { + images: title.images.map((img) => img.url), + defaultActiveIndex: index + } + ) + ] }) + ] }) + ] }, image.id)) }), + !title.images.length && /* @__PURE__ */ jsx(NoImagesMessage, {}) + ] }); +} +function NoImagesMessage() { + return /* @__PURE__ */ jsx( + IllustratedMessage, + { + className: "mt-40", + imageMargin: "mb-8", + image: /* @__PURE__ */ jsx("div", { className: "text-muted", children: /* @__PURE__ */ jsx(ImageIcon, { size: "xl" }) }), + imageHeight: "h-auto", + title: /* @__PURE__ */ jsx(Trans, { message: "No images have been added yet" }) + } + ); +} +const MAX_IMAGE_SIZE = 5e6; +function UploadButton() { + const { titleId } = useParams(); + const uploadImage2 = useUploadImage(); + const selectAndUploadFile = async () => { + const files = await openUploadWindow({ + types: [UploadInputType.image] + }); + const errorMessage = validateUpload(files[0], { + maxFileSize: MAX_IMAGE_SIZE + }); + if (errorMessage) { + toast.danger(errorMessage); + return; + } + uploadImage2.mutate({ + file: files[0].native, + titleId + }); + }; + return /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "primary", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + disabled: uploadImage2.isPending, + onClick: () => selectAndUploadFile(), + children: /* @__PURE__ */ jsx(Trans, { message: "Upload image" }) + } + ); +} +function DeleteButton$3({ imageId }) { + const deleteImage2 = useDeleteImage(imageId); + return /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + size: "xs", + disabled: deleteImage2.isPending, + onClick: () => deleteImage2.mutate(), + children: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ); +} +const SortOptions = [ + { + value: "created_at:desc", + label: message("Newest") + }, + { + value: "created_at:asc", + label: message("Oldest") + }, + { + value: "upvotes:desc", + label: message("Most upvotes") + }, + { + value: "reports_count:desc", + label: message("Most reported") + }, + { + value: "season_num:desc", + label: message("Seasons") + }, + { + value: "order:asc", + label: message("Curated") + } +]; +function TitleVideosSortButton({ value, onValueChange, color }) { + let selectedOption = SortOptions.find((option) => option.value === value); + if (!selectedOption) { + selectedOption = SortOptions[0]; + } + return /* @__PURE__ */ jsxs( + MenuTrigger, + { + selectedValue: value, + onSelectionChange: (newValue) => onValueChange(newValue), + selectionMode: "single", + children: [ + /* @__PURE__ */ jsx(Button, { variant: "outline", startIcon: /* @__PURE__ */ jsx(SortIcon, {}), color, children: /* @__PURE__ */ jsx(Trans, { ...selectedOption.label }) }), + /* @__PURE__ */ jsx(Menu, { children: SortOptions.map((option) => /* @__PURE__ */ jsx(Item, { value: option.value, children: /* @__PURE__ */ jsx(Trans, { ...option.label }) }, option.value)) }) + ] + } + ); +} +function useDeleteVideos() { + return useMutation({ + mutationFn: (payload) => deleteVideos(payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["video"] }); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function deleteVideos({ videoIds }) { + return apiClient.delete(`videos/${videoIds.join(",")}`).then((r) => r.data); +} +function useSeasonEpisodeNumbers() { + const { titleId, season } = useParams(); + return useQuery({ + queryKey: [ + "titles", + `${titleId}`, + "seasons", + `${season}`, + "episodeNumbers" + ], + queryFn: () => fetchEpisodeNumbers(titleId, season) + }); +} +function fetchEpisodeNumbers(titleId, seasonNumber) { + return apiClient.get(`titles/${titleId}/seasons/${seasonNumber}/episode-numbers`).then((response) => response.data); +} +function VideosEditorSeasonSelect({ title }) { + const navigate = useNavigate$1(); + const { trans } = useTrans(); + const params = useParams(); + const season = params.season ? Number(params.season) : ""; + const episode = params.episode ? Number(params.episode) : ""; + const handleNavigate = (season2, episode2) => { + let uri = `/admin/titles/${title.id}/edit/videos`; + if (season2) { + uri += `/seasons/${season2}`; + } + if (episode2) { + uri += `/episodes/${episode2}`; + } + navigate(uri); + }; + if (!title.seasons_count) { + return null; + } + return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-12", children: [ + /* @__PURE__ */ jsxs( + SelectForwardRef, + { + className: "flex-1", + selectedValue: season, + onSelectionChange: (newSeason) => { + handleNavigate(newSeason); + }, + placeholder: trans(message("Season")), + selectionMode: "single", + size: "sm", + children: [ + /* @__PURE__ */ jsx(Item, { value: "", children: /* @__PURE__ */ jsx(Trans, { message: "All seasons" }) }, "none"), + [...new Array(title.seasons_count).keys()].map((i) => { + const number = i + 1; + return /* @__PURE__ */ jsx(Item, { value: number, children: /* @__PURE__ */ jsx(Trans, { message: "Season :number", values: { number } }) }, number); + }) + ] + } + ), + season && /* @__PURE__ */ jsx( + EpisodeSelect, + { + value: episode, + onChange: (newEpisode) => { + handleNavigate(season, newEpisode); + } + } + ) + ] }); +} +function EpisodeSelect({ value, onChange }) { + const { trans } = useTrans(); + const { data } = useSeasonEpisodeNumbers(); + return /* @__PURE__ */ jsxs( + SelectForwardRef, + { + placeholder: trans(message("Episode")), + selectionMode: "single", + className: "flex-1", + size: "sm", + selectedValue: value, + onSelectionChange: onChange, + children: [ + /* @__PURE__ */ jsx(Item, { value: "", children: /* @__PURE__ */ jsx(Trans, { message: "All episodes" }) }, "none"), + data == null ? void 0 : data.episodeNumbers.map((number) => { + return /* @__PURE__ */ jsx(Item, { value: number, children: /* @__PURE__ */ jsx(Trans, { message: "Episode :number", values: { number } }) }, number); + }) + ] + } + ); +} +function TitleVideosEditor() { + const filters = useMemo( + () => VideosDatatableFilters.filter((f) => f.key !== "title_id"), + [] + ); + const { encodedFilters } = useBackendFilterUrlParams(filters); + const { season, episode } = useParams(); + const title = useOutletContext(); + const query = useInfiniteData({ + queryKey: ["video", "edit-title-page"], + endpoint: "videos", + defaultOrderBy: "created_at", + defaultOrderDir: "desc", + queryParams: { + perPage: 20, + filters: encodedFilters, + title_id: title.id, + season: season ?? null, + episode: episode ?? null + } + }); + let content; + if (query.data) { + content = query.items.length ? /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx("div", { className: "grid grid-cols-1 gap-24 md:grid-cols-2 lg:grid-cols-3", children: query.items.map((video) => /* @__PURE__ */ jsx(VideoItem, { video }, video.id)) }), + /* @__PURE__ */ jsx(InfiniteScrollSentinel, { query }) + ] }) : /* @__PURE__ */ jsx(NoVideosMessage, { isFiltering: encodedFilters != null }); + } else { + content = /* @__PURE__ */ jsx(TitleEditorPageStatus, { query }); + } + return /* @__PURE__ */ jsxs(TitleEditorLayout, { children: [ + /* @__PURE__ */ jsxs("div", { className: "mb-24 flex flex-wrap items-center gap-12", children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "primary", + startIcon: /* @__PURE__ */ jsx(AddIcon, {}), + elementType: Link, + to: "new", + className: "mr-auto", + children: /* @__PURE__ */ jsx(Trans, { message: "Add video" }) + } + ), + /* @__PURE__ */ jsx(VideosEditorSeasonSelect, { title }), + /* @__PURE__ */ jsx( + TitleVideosSortButton, + { + value: `${query.sortDescriptor.orderBy}:${query.sortDescriptor.orderDir}`, + onValueChange: (value) => { + const [orderBy, orderDir] = value.split(":"); + query.setSortDescriptor({ orderBy, orderDir }); + } + } + ), + /* @__PURE__ */ jsx( + AddFilterButton, + { + icon: /* @__PURE__ */ jsx(TuneIcon, {}), + color: null, + variant: "outline", + filters + } + ) + ] }), + /* @__PURE__ */ jsx(FilterList, { className: "mb-24", filters }), + content + ] }); +} +function NoVideosMessage({ isFiltering }) { + return /* @__PURE__ */ jsx( + IllustratedMessage, + { + className: "mt-40", + imageMargin: "mb-8", + image: /* @__PURE__ */ jsx("div", { className: "text-muted", children: /* @__PURE__ */ jsx(MediaPlayIcon, { size: "xl" }) }), + imageHeight: "h-auto", + title: isFiltering ? /* @__PURE__ */ jsx(Trans, { message: "No matching videos" }) : /* @__PURE__ */ jsx(Trans, { message: "No videos have been added yet" }) + } + ); +} +function VideoItem({ video }) { + const link = getWatchLink(video); + return /* @__PURE__ */ jsxs("div", { className: "", children: [ + /* @__PURE__ */ jsxs(Link, { to: link, className: "relative isolate block", target: "_blank", children: [ + /* @__PURE__ */ jsx(VideoThumbnail, { video, title: video.title, srcSize: "lg" }), + /* @__PURE__ */ jsx(VideoGridItemBottomGradient, {}), + /* @__PURE__ */ jsxs("span", { className: "absolute bottom-0 left-0 z-30 flex items-center gap-x-6 p-10 text-white", children: [ + /* @__PURE__ */ jsx(PlayCircleIcon, {}), + /* @__PURE__ */ jsx("span", { className: "capitalize", children: video.category }) + ] }) + ] }), + /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsxs("div", { className: "mb-4 mt-12 flex items-center gap-24", children: [ + /* @__PURE__ */ jsx(Link, { to: link, className: "block font-semibold hover:underline", children: video.name }), + video.reports_count ? /* @__PURE__ */ jsx("div", { className: "ml-auto flex-shrink-0 whitespace-nowrap text-sm text-muted", children: /* @__PURE__ */ jsx( + Trans, + { + message: ":count reports", + values: { count: video.reports_count } + } + ) }) : null + ] }), + /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-14 text-sm text-muted", children: [ + (video.season_num != null || video.episode_num != null) && /* @__PURE__ */ jsx( + CompactSeasonEpisode, + { + seasonNum: video.season_num, + episodeNum: video.episode_num + } + ), + /* @__PURE__ */ jsx(FormattedDate, { date: video.created_at }) + ] }), + /* @__PURE__ */ jsxs("div", { className: "mt-14 flex items-center gap-24", children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + size: "xs", + startIcon: /* @__PURE__ */ jsx(EditIcon, {}), + elementType: Link, + to: `edit/${video.id}`, + children: /* @__PURE__ */ jsx(Trans, { message: "Edit" }) + } + ), + /* @__PURE__ */ jsx(DeleteButton$2, { video }) + ] }) + ] }) + ] }); +} +function DeleteButton$2({ video }) { + const deleteVideos2 = useDeleteVideos(); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (confirmed) => { + if (confirmed) { + deleteVideos2.mutate({ videoIds: [video.id] }); + } + }, + children: [ + /* @__PURE__ */ jsx( + Button, + { + className: "ml-auto", + variant: "outline", + size: "xs", + startIcon: /* @__PURE__ */ jsx(DeleteIcon, {}), + disabled: deleteVideos2.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isDanger: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Delete video" }), + body: /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete this video?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ) + ] + } + ); +} +function useUpdateEpisode(titleId, season, episode, form) { + return useMutation({ + mutationFn: (payload) => updateEpisode(titleId, season, episode, payload), + onSuccess: async ({ episode: episode2 }) => { + await queryClient.invalidateQueries({ + queryKey: seasonQueryKey(episode2.title_id, episode2.season_number) + }); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function updateEpisode(titleId, season, episode, payload) { + return apiClient.put(`titles/${titleId}/seasons/${season}/episodes/${episode}`, payload).then((r) => r.data); +} +const PageTabs = [ + { uri: "primary-facts", label: message("Primary facts") }, + { uri: "cast", label: message("Cast") }, + { uri: "crew", label: message("Crew") } +]; +function EpisodeEditorLayout({ children, actions }) { + const { episode, season } = useParams(); + const navigate = useNavigate$1(); + const { pathname } = useLocation(); + const tabName = pathname.split("/").pop(); + const selectedTab = episode ? PageTabs.findIndex((tab) => tab.uri === tabName) : 0; + return /* @__PURE__ */ jsxs(TitleEditorLayout, { actions, children: [ + /* @__PURE__ */ jsxs(Breadcrumb, { className: "mb-24", children: [ + /* @__PURE__ */ jsx( + BreadcrumbItem, + { + onSelected: () => navigate("../..", { relative: "path" }), + children: /* @__PURE__ */ jsx(Trans, { message: "Season :number", values: { number: season } }) + } + ), + /* @__PURE__ */ jsx(BreadcrumbItem, { children: episode ? /* @__PURE__ */ jsx(Trans, { message: "Episode :number", values: { number: episode } }) : /* @__PURE__ */ jsx(Trans, { message: "New episode" }) }) + ] }), + /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsxs(Tabs, { selectedTab, children: [ + /* @__PURE__ */ jsx(TabList, { children: PageTabs.map((tab) => /* @__PURE__ */ jsx( + Tab, + { + isDisabled: !episode && tab.uri !== "primary-facts", + width: "min-w-132", + elementType: Link, + to: `../${tab.uri}`, + relative: "path", + replace: true, + children: /* @__PURE__ */ jsx(Trans, { ...tab.label }) + }, + tab.uri + )) }), + /* @__PURE__ */ jsx("div", { className: "pt-24", children }) + ] }) }) + ] }); +} +function useCreateEpisode(form, titleId, season) { + return useMutation({ + mutationFn: (payload) => createEpisode(titleId, season, payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: seasonQueryKey(titleId, season) + }); + }, + onError: (r) => form ? onFormQueryError(r, form) : showHttpErrorToast(r) + }); +} +function createEpisode(titleId, season, payload) { + return apiClient.post(`titles/${titleId}/seasons/${season}/episodes`, payload).then((r) => r.data); +} +function EpisodePrimaryFactsForm() { + const { episode: episodeNumber } = useParams(); + if (episodeNumber) { + return /* @__PURE__ */ jsx(UpdateEpisodePanel, {}); + } else { + return /* @__PURE__ */ jsx(NewEpisodeForm, {}); + } +} +function NewEpisodeForm() { + const title = useOutletContext(); + const { season } = useParams(); + const navigate = useNavigate$1(); + const now = useCurrentDateTime(); + const form = useForm({ + defaultValues: { + release_date: now.toAbsoluteString() + } + }); + const createEpisode2 = useCreateEpisode(form, title.id, season); + const isDirty = Object.keys(form.formState.dirtyFields).length > 0; + return /* @__PURE__ */ jsx( + Form$1, + { + form, + onSubmit: (values) => { + createEpisode2.mutate(values, { + onSuccess: (response) => { + toast(message("Episode created")); + navigate(`../${response.episode.episode_number}`, { + relative: "path" + }); + } + }); + }, + children: /* @__PURE__ */ jsx( + EpisodeEditorLayout, + { + actions: /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + type: "submit", + disabled: createEpisode2.isPending || !isDirty, + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ), + children: /* @__PURE__ */ jsx(FormFields, {}) + } + ) + } + ); +} +function UpdateEpisodePanel() { + const query = useEpisode("episode"); + return query.data ? /* @__PURE__ */ jsx(UpdateEpisodeForm, { episode: query.data.episode }) : /* @__PURE__ */ jsx( + EpisodeEditorLayout, + { + actions: /* @__PURE__ */ jsx(Button, { variant: "flat", color: "primary", type: "submit", disabled: true, children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) }), + children: /* @__PURE__ */ jsx(TitleEditorPageStatus, { query }) + } + ); +} +function UpdateEpisodeForm({ episode }) { + const title = useOutletContext(); + const navigate = useNavigate$1(); + const form = useForm({ + defaultValues: { + name: episode.name, + description: episode.description, + release_date: episode.release_date, + runtime: episode.runtime, + popularity: episode.popularity, + poster: episode.poster + } + }); + const updateEpisode2 = useUpdateEpisode( + title.id, + episode.season_number, + episode.episode_number, + form + ); + return /* @__PURE__ */ jsx( + Form$1, + { + form, + onSubmit: (values) => { + updateEpisode2.mutate(values, { + onSuccess: () => { + toast(message("Episode updated")); + navigate("../../../", { relative: "path" }); + } + }); + }, + children: /* @__PURE__ */ jsx( + EpisodeEditorLayout, + { + actions: /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + color: "primary", + type: "submit", + disabled: updateEpisode2.isPending || !form.formState.isDirty, + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ), + children: /* @__PURE__ */ jsx(FormFields, {}) + } + ) + } + ); +} +function FormFields() { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs("div", { className: "gap-24 md:flex", children: [ + /* @__PURE__ */ jsx( + FormImageSelector, + { + variant: "square", + previewSize: "w-204 aspect-poster", + name: "poster", + diskPrefix: "episode-posters", + label: /* @__PURE__ */ jsx(Trans, { message: "Poster" }), + stretchPreview: true + } + ), + /* @__PURE__ */ jsxs("div", { className: "mb-24 flex-auto max-md:mt-24", children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Title" }), + className: "mb-24", + required: true + } + ), + /* @__PURE__ */ jsx( + FormDatePicker, + { + name: "release_date", + label: /* @__PURE__ */ jsx(Trans, { message: "Release date" }), + className: "mb-24", + granularity: "day" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "runtime", + label: /* @__PURE__ */ jsx(Trans, { message: "Runtime" }), + type: "number", + min: 1, + className: "mb-24" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "popularity", + label: /* @__PURE__ */ jsx(Trans, { message: "Popularity" }), + type: "number", + min: 1 + } + ) + ] }) + ] }), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "description", + label: /* @__PURE__ */ jsx(Trans, { message: "Overview" }), + inputElementType: "textarea", + rows: 6, + className: "mb-24" + } + ) + ] }); +} +const titleCreditsQueryKey = (titleId, season, episode, params) => { + const key = ["titles", `${titleId}`, "credits"]; + if (season) { + key.push("season", `${season}`); + } + if (episode) { + key.push("episode", `${episode}`); + } + if (params) { + key.push(params); + } + return key; +}; +function useTitleCredits(params = {}) { + const { titleId, season, episode } = useParams(); + return useInfiniteData({ + endpoint: `titles/${titleId}/credits`, + queryKey: titleCreditsQueryKey(titleId, season, episode, params), + queryParams: { + ...params, + perPage: 30, + season: season || "", + episode: episode || "" + } + }); +} +function useSortTitleCredits() { + const { titleId } = useParams(); + return useMutation({ + mutationFn: (payload) => sortCredits(payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: titleCreditsQueryKey(titleId) + }); + toast(message("Credit added")); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function sortCredits(payload) { + return apiClient.post(`titles/credits/reorder`, payload).then((r) => r.data); +} +function useUpdateTitleCredit(form, creditId) { + const { titleId, season, episode } = useParams(); + return useMutation({ + mutationFn: (payload) => updateTitle(titleId, season, episode, creditId, payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: titleCreditsQueryKey(titleId) + }); + toast(message("Credit updated")); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function updateTitle(titleId, season, episode, creditId, payload) { + payload = { + ...payload, + season, + episode + }; + return apiClient.put(`titles/${titleId}/credits/${creditId}`, payload).then((r) => r.data); +} +function useCreateTitleCredit(form) { + const { titleId, season, episode } = useParams(); + return useMutation({ + mutationFn: (payload) => createCredit(titleId, season, episode, payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: titleCreditsQueryKey(titleId) + }); + toast(message("Credit added")); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function createCredit(titleId, season, episode, payload) { + payload = { + ...payload, + season, + episode + }; + return apiClient.post(`titles/${titleId}/credits`, payload).then((r) => r.data); +} +function AddCreditDialog({ isCrew }) { + const { formId, close } = useDialogContext(); + const form = useForm({ + defaultValues: { + department: !isCrew ? "actors" : void 0, + job: !isCrew ? "actor" : void 0 + } + }); + const createCredit2 = useCreateTitleCredit(form); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Create credit" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs( + Form$1, + { + id: formId, + form, + onSubmit: (values) => { + createCredit2.mutate(values, { onSuccess: () => close() }); + }, + children: [ + /* @__PURE__ */ jsx( + FormNormalizedModelField, + { + endpoint: "normalized-models/person", + name: "person_id", + label: /* @__PURE__ */ jsx(Trans, { message: "Person" }), + className: "mb-24", + autoFocus: true + } + ), + /* @__PURE__ */ jsx(SharedCreditDialogFields, { isCrew }) + ] + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: () => close(), children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx( + Button, + { + form: formId, + type: "submit", + variant: "flat", + color: "primary", + disabled: createCredit2.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Create" }) + } + ) + ] }) + ] }); +} +function SharedCreditDialogFields({ + isCrew +}) { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "character", + label: /* @__PURE__ */ jsx(Trans, { message: "Character" }), + required: !isCrew, + className: clsx("mb-24", isCrew && "hidden") + } + ), + /* @__PURE__ */ jsx(CrewFields, { isCrew }) + ] }); +} +function CrewFields({ isCrew }) { + const { data } = useValueLists(["tmdbDepartments"]); + const { watch } = useFormContext(); + const selectedDepartment = watch("department"); + const { jobs, departments } = useMemo(() => { + const departments2 = (data == null ? void 0 : data.tmdbDepartments.map((d) => ({ + department: d.department.toLowerCase(), + jobs: d.jobs + }))) || []; + const department = departments2.find( + (d) => d.department === selectedDepartment + ); + const jobs2 = (department == null ? void 0 : department.jobs.map((job) => ({ job: job.toLowerCase() }))) || []; + return { + jobs: jobs2, + departments: departments2 + }; + }, [data, selectedDepartment]); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FormSelect, + { + name: "department", + label: /* @__PURE__ */ jsx(Trans, { message: "Department" }), + required: true, + disabled: !isCrew, + items: departments, + className: "mb-24", + selectionMode: "single", + showSearchField: true, + children: (item) => /* @__PURE__ */ jsx(Item, { value: item.department, children: /* @__PURE__ */ jsx(Trans, { message: item.department }) }) + } + ), + /* @__PURE__ */ jsx( + FormSelect, + { + name: "job", + label: /* @__PURE__ */ jsx(Trans, { message: "Job" }), + required: true, + disabled: !isCrew, + items: jobs, + selectionMode: "single", + showSearchField: true, + children: (item) => /* @__PURE__ */ jsx(Item, { value: item.job, children: /* @__PURE__ */ jsx(Trans, { message: item.job }) }, item.job) + } + ) + ] }); +} +function EditCreditDialog({ credit }) { + const { formId, close } = useDialogContext(); + const isCrew = credit.pivot.department !== "actors"; + const form = useForm({ + defaultValues: { + character: credit.pivot.character, + department: credit.pivot.department, + job: credit.pivot.job + } + }); + const updateCredit = useUpdateTitleCredit(form, credit.pivot.id); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Edit credit" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs( + Form$1, + { + id: formId, + form, + onSubmit: (values) => { + updateCredit.mutate(values, { onSuccess: () => close() }); + }, + children: [ + /* @__PURE__ */ jsx( + TextField, + { + value: credit.name, + label: /* @__PURE__ */ jsx(Trans, { message: "Person" }), + required: true, + readOnly: true, + disabled: true, + className: "mb-24" + } + ), + /* @__PURE__ */ jsx(SharedCreditDialogFields, { isCrew }) + ] + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: () => close(), children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx( + Button, + { + form: formId, + type: "submit", + variant: "flat", + color: "primary", + disabled: updateCredit.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ) + ] }) + ] }); +} +function useDeleteTitleCredit(creditId) { + const { titleId, season, episode } = useParams(); + return useMutation({ + mutationFn: () => deleteCredit$1(titleId, season, episode, creditId), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: titleCreditsQueryKey(titleId) + }); + toast(message("Credit deleted")); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function deleteCredit$1(titleId, season, episode, creditId) { + return apiClient.delete(`titles/${titleId}/credits/${creditId}`, { + params: { season, episode } + }).then((r) => r.data); +} +const getCreditsEditorActionColumn = () => { + return { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + align: "end", + width: "w-84 flex-shrink-0", + visibleInMode: "all", + body: (item) => /* @__PURE__ */ jsxs("div", { className: "text-muted", children: [ + /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(IconButton, { children: /* @__PURE__ */ jsx(EditIcon, {}) }), + /* @__PURE__ */ jsx(EditCreditDialog, { credit: item }) + ] }), + /* @__PURE__ */ jsx(DeleteButton$1, { creditId: item.pivot.id }) + ] }) + }; +}; +function DeleteButton$1({ creditId }) { + const deleteCredit2 = useDeleteTitleCredit(creditId); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (confirmed) => { + if (confirmed) { + deleteCredit2.mutate(); + } + }, + children: [ + /* @__PURE__ */ jsx(IconButton, { children: /* @__PURE__ */ jsx(DeleteIcon, {}) }), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isDanger: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Delete credit" }), + body: /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete this credit?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ) + ] + } + ); +} +const RecentActorsIcon = createSvgIcon( + [/* @__PURE__ */ jsx("path", { d: "M21 5h2v14h-2zm-4 0h2v14h-2zm-3 0H2c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V6c0-.55-.45-1-1-1zm-1 12H3V7h10v10z" }, "0"), /* @__PURE__ */ jsx("circle", { cx: "8", cy: "9.94", r: "1.95" }, "1"), /* @__PURE__ */ jsx("path", { d: "M11.89 15.35c0-1.3-2.59-1.95-3.89-1.95s-3.89.65-3.89 1.95V16h7.78v-.65z" }, "2")], + "RecentActorsOutlined" +); +function CreditsTableQueryIndicator({ query }) { + if (query.data && !query.items.length) { + return /* @__PURE__ */ jsx(NoCreditsMessage$1, {}); + } + if (!query.data) { + return /* @__PURE__ */ jsx(TitleEditorPageStatus, { query }); + } + return /* @__PURE__ */ jsx(InfiniteScrollSentinel, { query }); +} +function NoCreditsMessage$1() { + return /* @__PURE__ */ jsx( + IllustratedMessage, + { + className: "mt-40", + imageMargin: "mb-8", + image: /* @__PURE__ */ jsx("div", { className: "text-muted", children: /* @__PURE__ */ jsx(RecentActorsIcon, { size: "xl" }) }), + imageHeight: "h-auto", + title: /* @__PURE__ */ jsx(Trans, { message: "No credits have been added yet" }) + } + ); +} +const columnConfig$4 = [ + { + key: "dragHandle", + width: "w-42 flex-shrink-0", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Drag handle" }), + hideHeader: true, + body: () => /* @__PURE__ */ jsx(DragHandleIcon, { className: "cursor-pointer text-muted hover:text" }) + }, + { + key: "name", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Person" }), + visibleInMode: "all", + body: (credit) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-12", children: [ + /* @__PURE__ */ jsx(PersonPoster, { rounded: true, person: credit, size: "w-44" }), + /* @__PURE__ */ jsx("div", { className: "min-w-0 overflow-hidden", children: credit.name }) + ] }) + }, + { + key: "character", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Character" }), + body: (credit) => credit.pivot.character + }, + getCreditsEditorActionColumn() +]; +function CastEditorTable({ query }) { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + Table, + { + enableSelection: false, + columns: columnConfig$4, + data: query.items, + renderRowAs: CreditsTableRow, + cellHeight: "h-54" + } + ), + /* @__PURE__ */ jsx(CreditsTableQueryIndicator, { query }) + ] }); +} +function CreditsTableRow({ + item, + children, + className, + ...domProps +}) { + const isTouchDevice = useIsTouchDevice(); + const context = useContext(TableContext); + const domRef = useRef(null); + const previewRef = useRef(null); + const credits = context.data; + const sortCredits2 = useSortTitleCredits(); + const { sortableProps } = useSortable({ + ref: domRef, + disabled: isTouchDevice ?? false, + item, + items: credits, + type: "cast-editor-item", + preview: previewRef, + strategy: "line", + onSortEnd: (oldIndex, newIndex) => { + const ids = credits.map((item2) => item2.pivot.id); + const sortedIds = moveItemInNewArray(ids, oldIndex, newIndex); + sortCredits2.mutate({ ids: sortedIds }); + } + }); + return /* @__PURE__ */ jsxs( + "div", + { + className, + ref: domRef, + ...mergeProps(sortableProps, domProps), + children: [ + children, + !item.isPlaceholder && /* @__PURE__ */ jsx(RowDragPreview, { item, ref: previewRef }) + ] + } + ); +} +const RowDragPreview = React.forwardRef(({ item }, ref) => { + return /* @__PURE__ */ jsx(DragPreview, { ref, children: () => /* @__PURE__ */ jsx("div", { className: "rounded bg-chip p-8 text-sm shadow", children: item.name }) }); +}); +function TitleCreditsTableHeader({ query, isCrew }) { + const { trans } = useTrans(); + return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-24 justify-between mb-14", children: [ + /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(Button, { variant: "outline", color: "primary", startIcon: /* @__PURE__ */ jsx(AddIcon, {}), children: /* @__PURE__ */ jsx(Trans, { message: "Add credit" }) }), + /* @__PURE__ */ jsx(AddCreditDialog, { isCrew }) + ] }), + /* @__PURE__ */ jsx( + TextField, + { + size: "sm", + value: query.searchQuery, + onChange: (e) => query.setSearchQuery(e.target.value), + placeholder: trans(message("Search")), + startAdornment: /* @__PURE__ */ jsx(SearchIcon, {}) + } + ) + ] }); +} +function EpisodeCastEditor() { + const query = useTitleCredits({ + department: "actors" + }); + return /* @__PURE__ */ jsxs(EpisodeEditorLayout, { children: [ + /* @__PURE__ */ jsx(TitleCreditsTableHeader, { query, isCrew: false }), + /* @__PURE__ */ jsx(CastEditorTable, { query }) + ] }); +} +function TitleCastEditor() { + const query = useTitleCredits({ + department: "actors" + }); + return /* @__PURE__ */ jsxs(TitleEditorLayout, { children: [ + /* @__PURE__ */ jsx(TitleCreditsTableHeader, { query, isCrew: false }), + /* @__PURE__ */ jsx(CastEditorTable, { query }) + ] }); +} +const columnConfig$3 = [ + { + key: "name", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Person" }), + visibleInMode: "all", + body: (credit) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-12", children: [ + /* @__PURE__ */ jsx(PersonPoster, { rounded: true, person: credit, size: "w-44" }), + /* @__PURE__ */ jsx("div", { className: "overflow-hidden min-w-0", children: credit.name }) + ] }) + }, + { + key: "department", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Department" }), + body: (credit) => credit.pivot.department + }, + { + key: "job", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Job" }), + body: (credit) => credit.pivot.job + }, + getCreditsEditorActionColumn() +]; +function CrewEditorTable({ query }) { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + Table, + { + enableSelection: false, + columns: columnConfig$3, + data: query.items, + cellHeight: "h-54", + tableBody: /* @__PURE__ */ jsx(CreditsTableBody$1, {}) + } + ), + /* @__PURE__ */ jsx(CreditsTableQueryIndicator, { query }) + ] }); +} +function CreditsTableBody$1({ renderRowAs }) { + const { data } = useContext(TableContext); + return /* @__PURE__ */ jsx(Fragment, { children: data.map((item, rowIndex) => /* @__PURE__ */ jsx( + TableRow, + { + item, + index: rowIndex, + renderAs: renderRowAs + }, + item.pivot.id + )) }); +} +function TitleCrewEditor() { + const query = useTitleCredits({ + crewOnly: "true" + }); + return /* @__PURE__ */ jsxs(TitleEditorLayout, { children: [ + /* @__PURE__ */ jsx(TitleCreditsTableHeader, { query, isCrew: true }), + /* @__PURE__ */ jsx(CrewEditorTable, { query }) + ] }); +} +function SeasonCastEditor() { + const query = useTitleCredits({ + department: "actors" + }); + return /* @__PURE__ */ jsxs(SeasonEditorLayout, { children: [ + /* @__PURE__ */ jsx(TitleCreditsTableHeader, { query, isCrew: false }), + /* @__PURE__ */ jsx(CastEditorTable, { query }) + ] }); +} +function SeasonCrewEditor() { + const query = useTitleCredits({ + crewOnly: "true" + }); + return /* @__PURE__ */ jsxs(SeasonEditorLayout, { children: [ + /* @__PURE__ */ jsx(TitleCreditsTableHeader, { query, isCrew: true }), + /* @__PURE__ */ jsx(CrewEditorTable, { query }) + ] }); +} +function EpisodeCrewEditor() { + const query = useTitleCredits({ + crewOnly: "true" + }); + return /* @__PURE__ */ jsxs(EpisodeEditorLayout, { children: [ + /* @__PURE__ */ jsx(TitleCreditsTableHeader, { query, isCrew: true }), + /* @__PURE__ */ jsx(CrewEditorTable, { query }) + ] }); +} +function useDetachTitleTag(tag) { + const { titleId } = useParams(); + return useMutation({ + mutationFn: () => detachTag(titleId, tag), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["titles", `${titleId}`] }); + toast(message("Tag detached")); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function detachTag(titleId, tag) { + return apiClient.delete(`titles/${titleId}/tags/${tag.model_type}/${tag.id}`).then((r) => r.data); +} +function useAttachTitleTag(form, tagType) { + const { titleId } = useParams(); + return useMutation({ + mutationFn: (payload) => attachTag(titleId, tagType, payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["titles", `${titleId}`] + }); + toast(message("Tag attached")); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function attachTag(titleId, tagType, payload) { + return apiClient.post(`titles/${titleId}/tags/${tagType}`, payload).then((r) => r.data); +} +function AddTitleTagDialog({ type }) { + const { formId, close } = useDialogContext(); + const form = useForm(); + const attachTag2 = useAttachTitleTag(form, type); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Add :name", values: { name: type.replace("_", " ") } }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsx( + Form$1, + { + id: formId, + form, + onSubmit: (values) => { + attachTag2.mutate(values, { onSuccess: () => close() }); + }, + children: /* @__PURE__ */ jsx(NameField, { type }) + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: () => close(), children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx( + Button, + { + form: formId, + type: "submit", + variant: "flat", + color: "primary", + disabled: attachTag2.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Add" }) + } + ) + ] }) + ] }); +} +function NameField({ type }) { + const [query, setQuery] = useState(""); + const { isFetching, data } = useNormalizedModels(`normalized-models/${type}`, { + query + }); + return /* @__PURE__ */ jsx( + FormComboBox, + { + isAsync: true, + name: "tag_name", + isLoading: isFetching, + inputValue: query, + onInputValueChange: setQuery, + items: data == null ? void 0 : data.results, + allowCustomValue: true, + autoFocus: true, + children: (item) => /* @__PURE__ */ jsx(Item, { value: item.name, textLabel: item.name, children: /* @__PURE__ */ jsx(Trans, { message: item.description || item.name }) }, item.id) + } + ); +} +const columnConfig$2 = [ + { + key: "name", + header: () => /* @__PURE__ */ jsx(Trans, { message: "ID" }), + visibleInMode: "all", + body: (tag) => /* @__PURE__ */ jsx("span", { children: tag.name }) + }, + { + key: "display_name", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Display name" }), + body: (tag) => /* @__PURE__ */ jsx("span", { children: tag.display_name }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + align: "end", + width: "w-42 flex-shrink-0", + visibleInMode: "all", + body: (tag) => /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(IconButton, { className: "text-muted", children: /* @__PURE__ */ jsx(CloseIcon, {}) }), + /* @__PURE__ */ jsx(DetachTagDialog, { tag }) + ] }) + } +]; +function TitleTagsEditor({ type }) { + const data = useTableData({ type }); + return /* @__PURE__ */ jsxs(TitleEditorLayout, { children: [ + /* @__PURE__ */ jsx("div", { className: "mb-14", children: /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(Button, { variant: "outline", color: "primary", startIcon: /* @__PURE__ */ jsx(AddIcon, {}), children: /* @__PURE__ */ jsx( + Trans, + { + message: "Add :name", + values: { name: type.replace("_", " ") } + } + ) }), + /* @__PURE__ */ jsx(AddTitleTagDialog, { type }) + ] }) }), + /* @__PURE__ */ jsx( + Table, + { + enableSelection: false, + columns: columnConfig$2, + data + } + ) + ] }); +} +function useTableData({ type }) { + const title = useOutletContext(); + switch (type) { + case GENRE_MODEL: + return title.genres; + case KEYWORD_MODEL: + return title.keywords; + case PRODUCTION_COUNTRY_MODEL: + return title.production_countries; + } +} +function DetachTagDialog({ tag }) { + const { close } = useDialogContext(); + const detachTag2 = useDetachTitleTag(tag); + const modelName = tag.model_type.replace("_", " "); + return /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isLoading: detachTag2.isPending, + isDanger: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Detach :name", values: { name: modelName } }), + body: /* @__PURE__ */ jsx( + Trans, + { + message: "Are you sure you want to detach this :name?", + values: { name: modelName } + } + ), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Detach" }), + onConfirm: () => { + detachTag2.mutate(void 0, { + onSuccess: () => close() + }); + } + } + ); +} +function TitleCommentsEditor() { + const title = useOutletContext(); + return /* @__PURE__ */ jsx(TitleEditorLayout, { children: /* @__PURE__ */ jsx(CommentsDatatablePage, { hideTitle: true, commentable: title }) }); +} +const awardsImage = "/assets/awards-411fec7f.svg"; +const PeopleDatatableColumns = [ + { + key: "name", + allowsSorting: true, + width: "flex-3", + visibleInMode: "all", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Person" }), + body: (person) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-12", children: [ + /* @__PURE__ */ jsx(PersonPoster, { person, srcSize: "sm", size: "w-32", rounded: true }), + /* @__PURE__ */ jsxs("div", { className: "overflow-hidden min-w-0", children: [ + /* @__PURE__ */ jsx("div", { className: "overflow-hidden overflow-ellipsis", children: /* @__PURE__ */ jsx(PersonLink, { person, target: "_blank" }) }), + /* @__PURE__ */ jsx("div", { className: "text-muted text-xs overflow-hidden overflow-ellipsis", children: /* @__PURE__ */ jsx( + KnownForCompact, + { + person, + linkTarget: "_blank", + linkColor: "inherit" + } + ) }) + ] }) + ] }) + }, + { + key: "birth_date", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Birth date" }), + body: (person) => /* @__PURE__ */ jsx(FormattedDate, { date: person.birth_date }) + }, + { + key: "views", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Page views" }), + body: (person) => person.views ? /* @__PURE__ */ jsx(FormattedNumber, { value: person.views }) : null, + width: "w-124 flex-shrink-0" + }, + { + key: "popularity", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Popularity" }), + body: (person) => person.popularity ? /* @__PURE__ */ jsx(FormattedNumber, { value: person.popularity }) : null, + width: "w-124 flex-shrink-0" + }, + { + key: "updated_at", + allowsSorting: true, + width: "w-124 flex-shrink-0", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (person) => person.updated_at ? /* @__PURE__ */ jsx(FormattedDate, { date: person.updated_at }) : "" + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + visibleInMode: "all", + align: "end", + width: "w-42 flex-shrink-0", + body: (video) => /* @__PURE__ */ jsx(Link, { to: `${video.id}/edit/primary-facts`, className: "text-muted", children: /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Edit" }), children: /* @__PURE__ */ jsx(IconButton, { size: "md", children: /* @__PURE__ */ jsx(EditIcon, {}) }) }) }) + } +]; +const PeopleDatatableFilters = [ + { + key: "known_for", + label: message("Known for"), + description: message("What role is person known for"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "acting", + options: [ + { + label: message("Acting"), + key: "acting", + value: "acting" + }, + { + label: message("Directing"), + key: "directing", + value: "directing" + }, + { + label: message("Production"), + key: "production", + value: "production" + }, + { label: message("Writing"), key: "writing", value: "writing" }, + { label: message("Crew"), key: "crew", value: "crew" }, + { label: message("Art"), key: "art", value: "art" }, + { + label: message("Costume & Make-Up"), + key: "Costume & Make-Up", + value: "Costume & Make-Up" + }, + { label: message("Camera"), key: "camera", value: "camera" }, + { label: message("Editing"), key: "editing", value: "editing" }, + { + label: message("Visual Effects"), + key: "visual effects", + value: "visual effects" + }, + { label: message("Sound"), key: "sound", value: "sound" }, + { label: message("Lighting"), key: "lighting", value: "lighting" }, + { label: message("Creator"), key: "creator", value: "creator" } + ] + } + }, + { + key: "gender", + label: message("Gender"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "male", + options: [ + { + label: message("Male"), + key: "male", + value: "male" + }, + { + label: message("Female"), + key: "female", + value: "female" + } + ] + } + }, + { + key: "poster", + label: message("No poster"), + description: message("Whether person has a poster"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.BooleanToggle, + defaultValue: null + } + }, + { + key: "views", + label: message("Page views"), + description: message("Number of unique page views"), + defaultOperator: FilterOperator.lte, + operators: ALL_PRIMITIVE_OPERATORS, + control: { + type: FilterControlType.Input, + inputType: "number", + minValue: 1, + defaultValue: 100 + } + }, + timestampFilter({ + key: "birth_date", + label: message("Birth date"), + description: message("Date person was born") + }), + timestampFilter({ + key: "death_date", + label: message("Death date"), + description: message("Date person died") + }), + createdAtFilter({ + description: message("Date person was created") + }), + updatedAtFilter({ + description: message("Date person was last updated") + }) +]; +function PeopleDatatablePage() { + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "people", + title: /* @__PURE__ */ jsx(Trans, { message: "People" }), + columns: PeopleDatatableColumns, + filters: PeopleDatatableFilters, + actions: /* @__PURE__ */ jsx(Actions$5, {}), + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: awardsImage, + title: /* @__PURE__ */ jsx(Trans, { message: "No people have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching people" }) + } + ) + } + ); +} +function Actions$5() { + const { tmdb_is_setup } = useSettings(); + const navigate = useNavigate$1(); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + tmdb_is_setup && /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (item) => { + if (item) { + navigate(`/admin/people/${item.id}/edit/primary-facts`); + } + }, + children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Import using TheMovieDB ID" }), children: /* @__PURE__ */ jsx( + IconButton, + { + variant: "outline", + color: "primary", + className: "flex-shrink-0", + size: "sm", + children: /* @__PURE__ */ jsx(PublishIcon, {}) + } + ) }), + /* @__PURE__ */ jsx(ImportSingleFromTmdbDialog, { modelType: PERSON_MODEL }) + ] + } + ), + /* @__PURE__ */ jsx(DataTableAddItemButton, { elementType: Link, to: "new", children: /* @__PURE__ */ jsx(Trans, { message: "Add person" }) }) + ] }); +} +function useCreatePerson(form) { + return useMutation({ + mutationFn: (payload) => createPerson(payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["people"] }); + toast(message("Person created")); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function createPerson(payload) { + return apiClient.post(`people`, payload).then((r) => r.data); +} +function PersonPrimaryFactsForm() { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs("div", { className: "mb-24 gap-24 md:flex", children: [ + /* @__PURE__ */ jsx( + FormImageSelector, + { + variant: "square", + previewSize: "w-204 aspect-poster", + name: "poster", + diskPrefix: "person-posters", + label: /* @__PURE__ */ jsx(Trans, { message: "Poster" }), + showRemoveButton: true + } + ), + /* @__PURE__ */ jsxs("div", { className: "flex-auto max-md:mt-24", children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Name" }), + className: "mb-24", + required: true + } + ), + /* @__PURE__ */ jsx(KnownForField, {}), + /* @__PURE__ */ jsx( + FormDatePicker, + { + name: "birth_date", + label: /* @__PURE__ */ jsx(Trans, { message: "Birth date" }), + className: "mb-24", + granularity: "day" + } + ), + /* @__PURE__ */ jsx( + FormDatePicker, + { + name: "death_date", + label: /* @__PURE__ */ jsx(Trans, { message: "Death date" }), + granularity: "day" + } + ) + ] }) + ] }), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "description", + label: /* @__PURE__ */ jsx(Trans, { message: "Biography" }), + inputElementType: "textarea", + rows: 4, + className: "mb-24" + } + ), + /* @__PURE__ */ jsx("div", { className: "mb-24 items-center gap-24 md:flex", children: /* @__PURE__ */ jsx( + FormTextField, + { + name: "birth_place", + label: /* @__PURE__ */ jsx(Trans, { message: "Birth place" }), + className: "flex-1 max-md:mb-24" + } + ) }), + /* @__PURE__ */ jsx("div", { className: "mb-24 items-center gap-24 md:flex", children: /* @__PURE__ */ jsxs( + FormSelect, + { + name: "gender", + label: /* @__PURE__ */ jsx(Trans, { message: "Gender" }), + className: "flex-1 max-md:mb-24", + selectionMode: "single", + children: [ + /* @__PURE__ */ jsx(Item, { value: "male", children: /* @__PURE__ */ jsx(Trans, { message: "Male" }) }), + /* @__PURE__ */ jsx(Item, { value: "female", children: /* @__PURE__ */ jsx(Trans, { message: "Female" }) }) + ] + } + ) }), + /* @__PURE__ */ jsx("div", { className: "mb-24 items-center gap-24 md:flex", children: /* @__PURE__ */ jsx( + FormTextField, + { + name: "popularity", + label: /* @__PURE__ */ jsx(Trans, { message: "Popularity" }), + type: "number", + min: 1, + className: "flex-1 max-md:mb-24" + } + ) }) + ] }); +} +function KnownForField() { + const { data } = useValueLists(["tmdbDepartments"]); + const departments = useMemo(() => { + return data == null ? void 0 : data.tmdbDepartments.map((item) => { + if (item.department === "Actors") { + return { department: "Acting" }; + } + return { department: item.department }; + }); + }, [data]); + return /* @__PURE__ */ jsx( + FormSelect, + { + name: "known_for", + label: /* @__PURE__ */ jsx(Trans, { message: "Known for" }), + required: true, + items: departments, + className: "mb-24", + selectionMode: "single", + showSearchField: true, + children: (item) => /* @__PURE__ */ jsx(Item, { value: item.department, children: /* @__PURE__ */ jsx(Trans, { message: item.department }) }) + } + ); +} +function CreatePersonPage() { + const navigate = useNavigate$1(); + const form = useForm({ + defaultValues: { + gender: "female", + known_for: "Acting", + popularity: 3 + } + }); + const createPerson2 = useCreatePerson(form); + return /* @__PURE__ */ jsx( + CrupdateResourceLayout, + { + onSubmit: (values) => createPerson2.mutate(values, { + onSuccess: (response) => { + navigate(`../${response.person.id}/edit`, { + relative: "path", + replace: true + }); + } + }), + form, + title: /* @__PURE__ */ jsx(Trans, { message: "New person" }), + isLoading: createPerson2.isPending, + disableSaveWhenNotDirty: true, + children: /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsx(PersonPrimaryFactsForm, {}) }) + } + ); +} +function useUpdatePerson(form) { + const { personId } = useParams(); + return useMutation({ + mutationFn: (payload) => updatePerson(payload, personId), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["people", `${personId}`] + }); + toast(message("Person updated")); + }, + onError: (r) => onFormQueryError(r, form) + }); +} +function updatePerson(payload, personId) { + return apiClient.put(`people/${personId}`, payload).then((r) => r.data); +} +function UpdatePersonPage() { + const query = usePerson("editPersonPage"); + return query.data ? /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(PageMetaTags, { query }), + /* @__PURE__ */ jsx(PageContent$2, { data: query.data }) + ] }) : /* @__PURE__ */ jsx("div", { className: "relative h-full w-full", children: /* @__PURE__ */ jsx(PageStatus, { query, loaderClassName: "absolute inset-0 m-auto" }) }); +} +function PageContent$2({ data }) { + const { person } = data; + const navigate = useNavigate$1(); + const form = useForm({ + defaultValues: { + name: person.name, + known_for: person.known_for, + poster: person.poster, + birth_date: person.birth_date, + death_date: person.death_date, + birth_place: person.birth_place, + description: person.description, + gender: person.gender, + popularity: person.popularity + } + }); + const updatePersonPage = useUpdatePerson(form); + const { pathname } = useLocation(); + const tabName = pathname.split("/").pop(); + const selectedTab = tabName === "credits" ? 1 : 0; + return /* @__PURE__ */ jsx( + CrupdateResourceLayout, + { + onSubmit: (values) => updatePersonPage.mutate(values, { + onSuccess: () => { + navigate("../../../", { relative: "path", replace: true }); + } + }), + form, + title: /* @__PURE__ */ jsx(Trans, { values: { name: person.name }, message: "Edit “:name“" }), + isLoading: updatePersonPage.isPending, + disableSaveWhenNotDirty: true, + children: /* @__PURE__ */ jsxs(Tabs, { selectedTab, children: [ + /* @__PURE__ */ jsxs(TabList, { children: [ + /* @__PURE__ */ jsx( + Tab, + { + elementType: Link, + to: `../primary-facts`, + relative: "path", + replace: true, + children: /* @__PURE__ */ jsx(Trans, { message: "Primary facts" }) + } + ), + /* @__PURE__ */ jsx(Tab, { elementType: Link, to: `../credits`, relative: "path", replace: true, children: /* @__PURE__ */ jsx(Trans, { message: "Credits" }) }) + ] }), + /* @__PURE__ */ jsx("div", { className: "min-h-512 pt-24", children: /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsx(Outlet, { context: data }) }) }) + ] }) + } + ); +} +function useDeletePersonCredit(credit) { + const { personId } = useParams(); + return useMutation({ + mutationFn: () => deleteCredit(credit.id, void 0, void 0, credit.pivot.id), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: titleCreditsQueryKey(credit.id) + }); + await queryClient.invalidateQueries({ + queryKey: ["people", `${personId}`] + }); + toast(message("Credit deleted")); + }, + onError: (r) => showHttpErrorToast(r) + }); +} +function deleteCredit(titleId, season, episode, creditId) { + return apiClient.delete(`titles/${titleId}/credits/${creditId}`, { + params: { season, episode } + }).then((r) => r.data); +} +const columnConfig$1 = [ + { + key: "name", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Credit" }), + visibleInMode: "all", + width: "flex-3", + body: (credit) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-12", children: [ + /* @__PURE__ */ jsx(TitlePoster, { title: credit, srcSize: "sm", size: "w-32" }), + /* @__PURE__ */ jsxs("div", { className: "overflow-hidden min-w-0", children: [ + /* @__PURE__ */ jsx("div", { className: "overflow-hidden overflow-ellipsis", children: /* @__PURE__ */ jsx(TitleLink, { title: credit, target: "_blank" }) }), + /* @__PURE__ */ jsx("div", { className: "text-muted text-xs overflow-hidden overflow-ellipsis", children: credit.is_series ? /* @__PURE__ */ jsx(Trans, { message: "Series" }) : /* @__PURE__ */ jsx(Trans, { message: "Movie" }) }) + ] }) + ] }) + }, + { + key: "year", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Year" }), + body: (credit) => credit.year + }, + { + key: "character", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Character" }), + body: (credit) => credit.pivot.character ? credit.pivot.character : "-" + }, + { + key: "department", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Department" }), + body: (credit) => /* @__PURE__ */ jsx("span", { className: "capitalize", children: credit.pivot.department }) + }, + { + key: "job", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Job" }), + body: (credit) => /* @__PURE__ */ jsx("span", { className: "capitalize", children: credit.pivot.job }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + align: "end", + width: "w-42 flex-shrink-0", + visibleInMode: "all", + body: (item) => /* @__PURE__ */ jsx("div", { className: "text-muted", children: /* @__PURE__ */ jsx(DeleteButton, { credit: item }) }) + } +]; +function PersonCreditsEditor() { + const data = useOutletContext(); + const credits = useMemo(() => { + return Object.values(data.credits).flat().filter((credit) => credit.pivot != null); + }, [data.credits]); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + Table, + { + enableSelection: false, + columns: columnConfig$1, + data: credits, + cellHeight: "h-54", + tableBody: /* @__PURE__ */ jsx(CreditsTableBody, {}) + } + ), + !credits.length && /* @__PURE__ */ jsx(NoCreditsMessage, {}) + ] }); +} +function CreditsTableBody({ renderRowAs }) { + const { data } = useContext(TableContext); + return /* @__PURE__ */ jsx(Fragment, { children: data.map((item, rowIndex) => /* @__PURE__ */ jsx( + TableRow, + { + item, + index: rowIndex, + renderAs: renderRowAs + }, + item.pivot.id + )) }); +} +function NoCreditsMessage() { + return /* @__PURE__ */ jsx( + IllustratedMessage, + { + className: "mt-40", + imageMargin: "mb-8", + image: /* @__PURE__ */ jsx("div", { className: "text-muted", children: /* @__PURE__ */ jsx(RecentActorsIcon, { size: "xl" }) }), + imageHeight: "h-auto", + title: /* @__PURE__ */ jsx(Trans, { message: "No credits have been added yet" }) + } + ); +} +function DeleteButton({ credit }) { + const deleteCredit2 = useDeletePersonCredit(credit); + return /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(IconButton, { children: /* @__PURE__ */ jsx(DeleteIcon, {}) }), + /* @__PURE__ */ jsx( + ConfirmationDialog, + { + isDanger: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Delete credit" }), + body: /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete this credit?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }), + isLoading: deleteCredit2.isPending, + onConfirm: () => deleteCredit2.mutate() + } + ) + ] }); +} +function ArticleEditorTitle() { + const [editingTitle, setEditingTitle] = useState(false); + const { trans } = useTrans(); + const form = useFormContext(); + const watchedTitle = form.watch("title"); + const titlePlaceholder = trans({ message: "Title" }); + if (editingTitle) { + return /* @__PURE__ */ jsx( + FormTextField, + { + placeholder: titlePlaceholder, + autoFocus: true, + className: "mb-30", + onBlur: () => { + setEditingTitle(false); + }, + name: "title", + required: true + } + ); + } + return /* @__PURE__ */ jsxs( + "h1", + { + tabIndex: 0, + onClick: () => { + setEditingTitle(true); + }, + onFocus: () => { + setEditingTitle(true); + }, + className: clsx( + "hover:bg-primary/focus rounded cursor-pointer", + !watchedTitle && "text-muted" + ), + children: [ + watchedTitle || titlePlaceholder, + /* @__PURE__ */ jsx(EditIcon, { className: "icon-sm mx-8 mt-8 align-top text-muted" }) + ] + } + ); +} +const UndoIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" }), + "UndoOutlined" +); +const RedoIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z" }), + "RedoOutlined" +); +function HistoryButtons({ editor }) { + return /* @__PURE__ */ jsxs("span", { children: [ + /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + disabled: !editor.can().undo(), + onClick: () => { + editor.commands.focus(); + editor.commands.undo(); + }, + children: /* @__PURE__ */ jsx(UndoIcon, {}) + } + ), + /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + disabled: !editor.can().redo(), + onClick: () => { + editor.commands.focus(); + editor.commands.redo(); + }, + children: /* @__PURE__ */ jsx(RedoIcon, {}) + } + ) + ] }); +} +const CodeIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M9.4 16.6 4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0 4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" }), + "CodeOutlined" +); +function ModeButton({ editor }) { + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "modal", + onClose: (newValue) => { + if (newValue != null) { + editor == null ? void 0 : editor.commands.setContent(newValue); + } + }, + children: [ + /* @__PURE__ */ jsx(Button, { variant: "text", startIcon: /* @__PURE__ */ jsx(CodeIcon, {}), children: /* @__PURE__ */ jsx(Trans, { message: "Source" }) }), + /* @__PURE__ */ jsx( + AceDialog, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Source code" }), + defaultValue: editor.getHTML() + } + ) + ] + } + ); +} +function Divider() { + return /* @__PURE__ */ jsx("div", { className: "self-stretch mx-4 w-1 bg-divider flex-shrink-0" }); +} +const FormatBoldIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" }), + "FormatBoldOutlined" +); +const FormatItalicIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4h-8z" }), + "FormatItalicOutlined" +); +const FormatUnderlinedIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z" }), + "FormatUnderlinedOutlined" +); +function FontStyleButtons({ editor, size }) { + return /* @__PURE__ */ jsxs("span", { className: clsx("flex-shrink-0 whitespace-nowrap"), children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Bold" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size, + color: editor.isActive("bold") ? "primary" : null, + onClick: () => { + editor.commands.focus(); + editor.commands.toggleBold(); + }, + children: /* @__PURE__ */ jsx(FormatBoldIcon, {}) + } + ) }), + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Italic" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size, + color: editor.isActive("italic") ? "primary" : null, + onClick: () => { + editor.commands.focus(); + editor.commands.toggleItalic(); + }, + children: /* @__PURE__ */ jsx(FormatItalicIcon, {}) + } + ) }), + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Underline" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size, + color: editor.isActive("underline") ? "primary" : null, + onClick: () => { + editor.commands.focus(); + editor.commands.toggleUnderline(); + }, + children: /* @__PURE__ */ jsx(FormatUnderlinedIcon, {}) + } + ) }) + ] }); +} +const FormatListBulletedIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" }), + "FormatListBulletedOutlined" +); +const FormatListNumberedIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" }), + "FormatListNumberedOutlined" +); +function ListButtons({ editor, size }) { + const bulletActive = editor.isActive("bulletList"); + const orderedActive = editor.isActive("orderedList"); + return /* @__PURE__ */ jsxs("span", { className: clsx("flex-shrink-0", "whitespace-nowrap"), children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Bulleted list" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size, + color: bulletActive ? "primary" : null, + onClick: () => { + editor.commands.focus(); + editor.commands.toggleBulletList(); + }, + children: /* @__PURE__ */ jsx(FormatListBulletedIcon, {}) + } + ) }), + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Numbered list" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size, + color: orderedActive ? "primary" : null, + onClick: () => { + editor.commands.focus(); + editor.commands.toggleOrderedList(); + }, + children: /* @__PURE__ */ jsx(FormatListNumberedIcon, {}) + } + ) }) + ] }); +} +function insertLinkIntoTextEditor(editor, { text, target, href }) { + if (editor.state.selection.empty && text) { + editor.commands.insertContent( + `${text}` + ); + } else if (!editor.state.selection.empty) { + if (!href) { + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + } else { + editor.chain().focus().extendMarkRange("link").setLink({ href, target }).run(); + } + } +} +function LinkButton({ editor, size }) { + return /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Insert link" }), children: /* @__PURE__ */ jsx(IconButton, { size, className: clsx("flex-shrink-0"), children: /* @__PURE__ */ jsx(LinkIcon, {}) }) }), + /* @__PURE__ */ jsx(LinkDialog, { editor }) + ] }); +} +function LinkDialog({ editor }) { + const previousUrl = editor.getAttributes("link").href; + const previousText = editor.state.doc.textBetween( + editor.state.selection.from, + editor.state.selection.to, + "" + ); + const form = useForm({ + defaultValues: { href: previousUrl, text: previousText, target: "_blank" } + }); + const { formId, close } = useDialogContext(); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Insert link" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs( + Form$1, + { + form, + id: formId, + onSubmit: (value) => { + insertLinkIntoTextEditor(editor, value); + close(); + }, + children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "href", + label: /* @__PURE__ */ jsx(Trans, { message: "URL" }), + autoFocus: true, + type: "url", + className: "mb-20" + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "text", + label: /* @__PURE__ */ jsx(Trans, { message: "Text to display" }), + className: "mb-20" + } + ), + /* @__PURE__ */ jsxs( + FormSelect, + { + selectionMode: "single", + name: "target", + label: /* @__PURE__ */ jsx(Trans, { message: "Open link in..." }), + children: [ + /* @__PURE__ */ jsx(Item, { value: "_self", children: /* @__PURE__ */ jsx(Trans, { message: "Current window" }) }), + /* @__PURE__ */ jsx(Item, { value: "_blank", children: /* @__PURE__ */ jsx(Trans, { message: "New window" }) }) + ] + } + ) + ] + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: close, variant: "text", children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx(Button, { type: "submit", form: formId, variant: "flat", color: "primary", children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) }) + ] }) + ] }); +} +const TwoMB = 2097152; +function ImageButton({ editor, size, diskPrefix = "page_media" }) { + const { selectAndUploadFile } = useActiveUpload(); + const handleUpload = () => { + selectAndUploadFile({ + showToastOnRestrictionFail: true, + restrictions: { + allowedFileTypes: [UploadInputType.image], + maxFileSize: TwoMB + }, + metadata: { + diskPrefix, + disk: Disk.public + }, + onSuccess: (entry) => { + editor.commands.focus(); + editor.commands.setImage({ + src: entry.url + }); + } + }); + }; + return /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Insert image" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size, + onClick: handleUpload, + className: clsx("flex-shrink-0"), + children: /* @__PURE__ */ jsx(ImageIcon, {}) + } + ) }); +} +const FormatClearIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M20 8V5H6.39l3 3h1.83l-.55 1.28 2.09 2.1L14.21 8zM3.41 4.86 2 6.27l6.97 6.97L6.5 19h3l1.57-3.66L16.73 21l1.41-1.41z" }), + "FormatClearOutlined" +); +function ClearFormatButton({ editor, size }) { + return /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Clear formatting" }), children: /* @__PURE__ */ jsx( + IconButton, + { + className: clsx("flex-shrink-0"), + size, + onClick: () => { + editor.chain().focus().clearNodes().unsetAllMarks().run(); + }, + children: /* @__PURE__ */ jsx(FormatClearIcon, {}) + } + ) }); +} +const HorizontalRuleIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { fillRule: "evenodd", d: "M4 11h16v2H4z" }), + "HorizontalRuleOutlined" +); +const PriorityHighIcon = createSvgIcon( + [/* @__PURE__ */ jsx("circle", { cx: "12", cy: "19", r: "2" }, "0"), /* @__PURE__ */ jsx("path", { d: "M10 3h4v12h-4z" }, "1")], + "PriorityHighOutlined" +); +const NoteIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M16 4H4c-1.1 0-2 .9-2 2v12.01c0 1.1.9 1.99 2 1.99h16c1.1 0 2-.9 2-2v-8l-6-6zM4 18.01V6h11v5h5v7.01H4z" }), + "NoteOutlined" +); +const SmartDisplayIcon = createSvgIcon( + [/* @__PURE__ */ jsx("path", { d: "M9.5 7.5v9l7-4.5z" }, "0"), /* @__PURE__ */ jsx("path", { d: "M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14.01H4V5.99h16v12.02z" }, "1")], + "SmartDisplayOutlined" +); +function InsertMenuTrigger({ editor, size }) { + const [dialog, setDialog] = useState(false); + return /* @__PURE__ */ jsxs(Fragment$1, { children: [ + /* @__PURE__ */ jsxs( + MenuTrigger, + { + onItemSelected: (key) => { + if (key === "hr") { + editor.commands.focus(); + editor.commands.setHorizontalRule(); + } else if (key === "embed") { + setDialog("embed"); + } else { + editor.commands.focus(); + editor.commands.addInfo({ type: key }); + } + }, + children: [ + /* @__PURE__ */ jsx( + IconButton, + { + variant: "text", + size, + className: clsx("flex-shrink-0"), + children: /* @__PURE__ */ jsx(MoreVertIcon, {}) + } + ), + /* @__PURE__ */ jsxs(Menu, { children: [ + /* @__PURE__ */ jsx(Item, { value: "hr", startIcon: /* @__PURE__ */ jsx(HorizontalRuleIcon, {}), children: /* @__PURE__ */ jsx(Trans, { message: "Horizontal rule" }) }), + /* @__PURE__ */ jsx(Item, { value: "embed", startIcon: /* @__PURE__ */ jsx(SmartDisplayIcon, {}), children: /* @__PURE__ */ jsx(Trans, { message: "Embed" }) }), + /* @__PURE__ */ jsx(Item, { value: "important", startIcon: /* @__PURE__ */ jsx(PriorityHighIcon, {}), children: /* @__PURE__ */ jsx(Trans, { message: "Important" }) }), + /* @__PURE__ */ jsx(Item, { value: "warning", startIcon: /* @__PURE__ */ jsx(WarningIcon, {}), children: /* @__PURE__ */ jsx(Trans, { message: "Warning" }) }), + /* @__PURE__ */ jsx(Item, { value: "success", startIcon: /* @__PURE__ */ jsx(NoteIcon, {}), children: /* @__PURE__ */ jsx(Trans, { message: "Note" }) }) + ] }) + ] + } + ), + /* @__PURE__ */ jsx( + DialogTrigger, + { + type: "modal", + isOpen: !!dialog, + onClose: () => { + setDialog(false); + }, + children: /* @__PURE__ */ jsx(EmbedDialog, { editor }) + } + ) + ] }); +} +function EmbedDialog({ editor }) { + const previousSrc = editor.getAttributes("embed").src; + const form = useForm({ + defaultValues: { src: previousSrc } + }); + const { formId, close } = useDialogContext(); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Insert link" }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsx( + Form$1, + { + form, + id: formId, + onSubmit: (value) => { + editor.commands.setEmbed(value); + close(); + }, + children: /* @__PURE__ */ jsx( + FormTextField, + { + name: "src", + label: /* @__PURE__ */ jsx(Trans, { message: "Embed URL" }), + autoFocus: true, + type: "url", + required: true + } + ) + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: close, variant: "text", children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx( + Button, + { + type: "submit", + form: formId, + disabled: !form.formState.isValid, + variant: "flat", + color: "primary", + children: /* @__PURE__ */ jsx(Trans, { message: "Add" }) + } + ) + ] }) + ] }); +} +function Keyboard({ children, modifier, separator = "+" }) { + const modKey = isMac() ? /* @__PURE__ */ jsx("span", { className: "text-base align-middle", children: "⌘" }) : "Ctrl"; + return /* @__PURE__ */ jsxs("kbd", { className: "text-xs text-muted", children: [ + modifier && /* @__PURE__ */ jsxs(Fragment$1, { children: [ + modKey, + separator + ] }), + children + ] }); +} +function FormatMenuTrigger({ editor, size }) { + return /* @__PURE__ */ jsxs( + MenuTrigger, + { + floatingMinWidth: "w-256", + onItemSelected: (key) => { + editor.commands.focus(); + if (typeof key === "string" && key.startsWith("h")) { + editor.commands.toggleHeading({ + level: parseInt(key.replace("h", "")) + }); + } else if (key === "code") { + editor.commands.toggleCode(); + } else if (key === "strike") { + editor.commands.toggleStrike(); + } else if (key === "super") { + editor.commands.toggleSuperscript(); + } else if (key === "sub") { + editor.commands.toggleSubscript(); + } else if (key === "blockquote") { + editor.commands.toggleBlockquote(); + } else if (key === "paragraph") { + editor.commands.setParagraph(); + } + }, + children: [ + /* @__PURE__ */ jsx( + Button, + { + className: clsx("flex-shrink-0"), + variant: "text", + size, + endIcon: /* @__PURE__ */ jsx(KeyboardArrowDownIcon, {}), + children: /* @__PURE__ */ jsx(Trans, { message: "Format" }) + } + ), + /* @__PURE__ */ jsxs(Menu, { children: [ + /* @__PURE__ */ jsx(Item, { value: "h1", endSection: /* @__PURE__ */ jsx(Keyboard, { modifier: true, children: "Alt+1" }), children: /* @__PURE__ */ jsx(Trans, { message: "Heading :number", values: { number: 1 } }) }), + /* @__PURE__ */ jsx(Item, { value: "h2", endSection: /* @__PURE__ */ jsx(Keyboard, { modifier: true, children: "Alt+2" }), children: /* @__PURE__ */ jsx(Trans, { message: "Heading :number", values: { number: 2 } }) }), + /* @__PURE__ */ jsx(Item, { value: "h3", endSection: /* @__PURE__ */ jsx(Keyboard, { modifier: true, children: "Alt+3" }), children: /* @__PURE__ */ jsx(Trans, { message: "Heading :number", values: { number: 3 } }) }), + /* @__PURE__ */ jsx(Item, { value: "h4", endSection: /* @__PURE__ */ jsx(Keyboard, { modifier: true, children: "Alt+4" }), children: /* @__PURE__ */ jsx(Trans, { message: "Heading :number", values: { number: 4 } }) }), + /* @__PURE__ */ jsx(Item, { value: "code", endSection: /* @__PURE__ */ jsx(Keyboard, { modifier: true, children: "E" }), children: /* @__PURE__ */ jsx(Trans, { message: "Code" }) }), + /* @__PURE__ */ jsx( + Item, + { + value: "strike", + endSection: /* @__PURE__ */ jsx(Keyboard, { modifier: true, children: "Shift+X" }), + children: /* @__PURE__ */ jsx(Trans, { message: "Strikethrough" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "super", + endSection: /* @__PURE__ */ jsx(Keyboard, { modifier: true, separator: " ", children: "." }), + children: /* @__PURE__ */ jsx(Trans, { message: "Superscript" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "sub", + endSection: /* @__PURE__ */ jsx(Keyboard, { modifier: true, separator: " ", children: "," }), + children: /* @__PURE__ */ jsx(Trans, { message: "Subscript" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "blockquote", + endSection: /* @__PURE__ */ jsx(Keyboard, { modifier: true, children: "Shift+B" }), + children: /* @__PURE__ */ jsx(Trans, { message: "Blockquote" }) + } + ), + /* @__PURE__ */ jsx( + Item, + { + value: "paragraph", + endSection: /* @__PURE__ */ jsx(Keyboard, { modifier: true, children: "Alt+0" }), + children: /* @__PURE__ */ jsx(Trans, { message: "Paragraph" }) + } + ) + ] }) + ] + } + ); +} +const FormatColorTextIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M2 20h20v4H2v-4zm3.49-3h2.42l1.27-3.58h5.65L16.09 17h2.42L13.25 3h-2.5L5.49 17zm4.42-5.61 2.03-5.79h.12l2.03 5.79H9.91z" }), + "FormatColorTextOutlined" +); +const FormatColorFillIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M16.56 8.94 7.62 0 6.21 1.41l2.38 2.38-5.15 5.15c-.59.59-.59 1.54 0 2.12l5.5 5.5c.29.29.68.44 1.06.44s.77-.15 1.06-.44l5.5-5.5c.59-.58.59-1.53 0-2.12zM5.21 10 10 5.21 14.79 10H5.21zM19 11.5s-2 2.17-2 3.5c0 1.1.9 2 2 2s2-.9 2-2c0-1.33-2-3.5-2-3.5zM2 20h20v4H2v-4z" }), + "FormatColorFillOutlined" +); +function ColorButtons({ editor, size }) { + const [dialog, setDialog] = useState(false); + const textActive = editor.getAttributes("textStyle").color; + const backgroundActive = editor.getAttributes("textStyle").backgroundColor; + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs("span", { className: clsx("flex-shrink-0 whitespace-nowrap"), children: [ + /* @__PURE__ */ jsx( + IconButton, + { + size, + color: textActive ? "primary" : null, + onClick: () => { + setDialog("text"); + }, + children: /* @__PURE__ */ jsx(FormatColorTextIcon, {}) + } + ), + /* @__PURE__ */ jsx( + IconButton, + { + size, + color: backgroundActive ? "primary" : null, + onClick: () => { + setDialog("bg"); + }, + children: /* @__PURE__ */ jsx(FormatColorFillIcon, {}) + } + ) + ] }), + /* @__PURE__ */ jsx( + DialogTrigger, + { + defaultValue: dialog === "text" ? "#000000" : "#FFFFFF", + type: "modal", + isOpen: !!dialog, + onClose: (newValue) => { + if (newValue) { + if (dialog === "text") { + editor.commands.setColor(newValue); + } else { + editor.commands.setBackgroundColor(newValue); + } + } + setDialog(false); + }, + children: /* @__PURE__ */ jsx(ColorPickerDialog, {}) + } + ) + ] }); +} +const FormatAlignLeftIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z" }), + "FormatAlignLeftOutlined" +); +const FormatAlignCenterIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z" }), + "FormatAlignCenterOutlined" +); +const FormatAlignRightIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M3 21h18v-2H3v2zm6-4h12v-2H9v2zm-6-4h18v-2H3v2zm6-4h12V7H9v2zM3 3v2h18V3H3z" }), + "FormatAlignRightOutlined" +); +const FormatAlignJustifyIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M3 21h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18V7H3v2zm0-6v2h18V3H3z" }), + "FormatAlignJustifyOutlined" +); +const iconMap = { + left: { + icon: FormatAlignLeftIcon, + label: message("Align left") + }, + center: { + icon: FormatAlignCenterIcon, + label: message("Align center") + }, + right: { + icon: FormatAlignRightIcon, + label: message("Align right") + }, + justify: { + icon: FormatAlignJustifyIcon, + label: message("Justify") + } +}; +function AlignButtons({ editor, size }) { + const activeKey = Object.keys(iconMap).find((key) => { + return editor.isActive({ textAlign: key }); + }) || "left"; + const ActiveIcon = activeKey ? iconMap[activeKey].icon : iconMap.left.icon; + return /* @__PURE__ */ jsxs( + MenuTrigger, + { + floatingWidth: "auto", + selectionMode: "single", + selectedValue: activeKey, + onSelectionChange: (key) => { + editor.commands.focus(); + editor.commands.setTextAlign(key); + }, + children: [ + /* @__PURE__ */ jsx( + IconButton, + { + size, + color: activeKey ? "primary" : null, + className: clsx("flex-shrink-0"), + children: /* @__PURE__ */ jsx(ActiveIcon, {}) + } + ), + /* @__PURE__ */ jsx(Menu, { children: Object.entries(iconMap).map(([name, config]) => { + const Icon = config.icon; + return /* @__PURE__ */ jsx( + Item, + { + value: name, + startIcon: /* @__PURE__ */ jsx(Icon, { size: "md" }), + capitalizeFirst: true, + children: /* @__PURE__ */ jsx(Trans, { message: config.label.message }) + }, + name + ); + }) }) + ] + } + ); +} +const FormatIndentDecreaseIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M11 17h10v-2H11v2zm-8-5 4 4V8l-4 4zm0 9h18v-2H3v2zM3 3v2h18V3H3zm8 6h10V7H11v2zm0 4h10v-2H11v2z" }), + "FormatIndentDecreaseOutlined" +); +const FormatIndentIncreaseIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M3 21h18v-2H3v2zM3 8v8l4-4-4-4zm8 9h10v-2H11v2zM3 3v2h18V3H3zm8 6h10V7H11v2zm0 4h10v-2H11v2z" }), + "FormatIndentIncreaseOutlined" +); +function IndentButtons({ editor, size }) { + return /* @__PURE__ */ jsxs("span", { className: clsx("flex-shrink-0", "whitespace-nowrap"), children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Decrease indent" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size, + onClick: () => { + editor.commands.focus(); + editor.commands.outdent(); + }, + children: /* @__PURE__ */ jsx(FormatIndentDecreaseIcon, {}) + } + ) }), + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Increase indent" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size, + onClick: () => { + editor.commands.focus(); + editor.commands.indent(); + }, + children: /* @__PURE__ */ jsx(FormatIndentIncreaseIcon, {}) + } + ) }) + ] }); +} +function CodeBlockMenuTrigger({ editor, size }) { + const language = editor.getAttributes("codeBlock").language || ""; + return /* @__PURE__ */ jsxs( + MenuTrigger, + { + selectionMode: "single", + selectedValue: language, + onSelectionChange: (key) => { + editor.commands.toggleCodeBlock({ language: key }); + }, + children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Codeblock" }), children: /* @__PURE__ */ jsx( + IconButton, + { + className: clsx("flex-shrink-0"), + size, + color: language ? "primary" : null, + children: /* @__PURE__ */ jsx(CodeIcon, {}) + } + ) }), + /* @__PURE__ */ jsxs(Menu, { children: [ + /* @__PURE__ */ jsx(Item, { value: "html", children: "HTML" }), + /* @__PURE__ */ jsx(Item, { value: "javascript", children: "JavaScript" }), + /* @__PURE__ */ jsx(Item, { value: "css", children: "CSS" }), + /* @__PURE__ */ jsx(Item, { value: "php", children: "PHP" }), + /* @__PURE__ */ jsx(Item, { value: "shell", children: "Shell" }), + /* @__PURE__ */ jsx(Item, { value: "bash", children: "Bash" }), + /* @__PURE__ */ jsx(Item, { value: "ruby", children: "Ruby" }), + /* @__PURE__ */ jsx(Item, { value: "python", children: "Python" }), + /* @__PURE__ */ jsx(Item, { value: "java", children: "Java" }), + /* @__PURE__ */ jsx(Item, { value: "c++", children: "C++" }) + ] }) + ] + } + ); +} +const MenubarRowClassName = "flex items-center px-4 h-42 text-muted border-b overflow-hidden"; +function ArticleBodyEditorMenubar({ + editor, + size = "md", + justify = "justify-center", + hideInsertButton = false, + imageDiskPrefix +}) { + const isMobile = useIsMobileMediaQuery(); + const [extendedVisible, setExtendedVisible] = useState(false); + return /* @__PURE__ */ jsxs("div", { className: clsx(extendedVisible ? "h-84" : "h-42"), children: [ + /* @__PURE__ */ jsxs("div", { className: clsx(MenubarRowClassName, justify, "relative z-20"), children: [ + /* @__PURE__ */ jsx(FormatMenuTrigger, { editor, size }), + /* @__PURE__ */ jsx(Divider, {}), + /* @__PURE__ */ jsx(FontStyleButtons, { editor, size }), + /* @__PURE__ */ jsx(Divider, {}), + /* @__PURE__ */ jsx(AlignButtons, { editor, size }), + /* @__PURE__ */ jsx(IndentButtons, { editor, size }), + /* @__PURE__ */ jsx(Divider, {}), + isMobile ? /* @__PURE__ */ jsx( + IconButton, + { + className: "flex-shrink-0", + color: extendedVisible ? "primary" : null, + size, + onClick: () => { + setExtendedVisible(!extendedVisible); + }, + children: extendedVisible ? /* @__PURE__ */ jsx(UnfoldLessIcon, {}) : /* @__PURE__ */ jsx(UnfoldMoreIcon, {}) + } + ) : /* @__PURE__ */ jsx( + ExtendedButtons, + { + editor, + size, + hideInsertButton, + imageDiskPrefix + } + ) + ] }), + /* @__PURE__ */ jsx(AnimatePresence, { children: extendedVisible && /* @__PURE__ */ jsx( + m.div, + { + className: clsx( + MenubarRowClassName, + justify, + "absolute flex h-full w-full" + ), + initial: { y: "-100%" }, + animate: { y: 0 }, + exit: { y: "-100%" }, + children: /* @__PURE__ */ jsx( + ExtendedButtons, + { + editor, + size, + imageDiskPrefix + } + ) + } + ) }) + ] }); +} +function ExtendedButtons({ + editor, + size = "md", + hideInsertButton, + imageDiskPrefix +}) { + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(ListButtons, { editor, size }), + /* @__PURE__ */ jsx(Divider, {}), + /* @__PURE__ */ jsx(LinkButton, { editor, size }), + /* @__PURE__ */ jsx(ImageButton, { editor, size, diskPrefix: imageDiskPrefix }), + !hideInsertButton && /* @__PURE__ */ jsx(InsertMenuTrigger, { editor, size }), + /* @__PURE__ */ jsx(Divider, {}), + /* @__PURE__ */ jsx(ColorButtons, { editor, size }), + /* @__PURE__ */ jsx(Divider, {}), + /* @__PURE__ */ jsx(CodeBlockMenuTrigger, { editor, size }), + /* @__PURE__ */ jsx(ClearFormatButton, { editor, size }) + ] }); +} +function ArticleEditorStickyHeader({ + editor, + allowSlugEditing = true, + onSave, + saveButton, + isLoading = false, + backLink, + slugPrefix = "pages", + imageDiskPrefix +}) { + const { isSticky, sentinelRef } = useStickySentinel(); + const isMobile = useIsMobileMediaQuery(); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx("div", { ref: sentinelRef }), + /* @__PURE__ */ jsxs("div", { className: clsx("sticky top-0 z-10 mb-20 bg", isSticky && "shadow"), children: [ + /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-20 border-b px-20 py-10 text-muted sm:justify-start", children: [ + !isMobile && /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "text", + size: "sm", + elementType: Link, + to: backLink, + relative: "path", + startIcon: /* @__PURE__ */ jsx(ArrowBackIcon, {}), + children: /* @__PURE__ */ jsx(Trans, { message: "Back" }) + } + ), + /* @__PURE__ */ jsx("div", { className: "mr-auto", children: allowSlugEditing && /* @__PURE__ */ jsx( + FormSlugEditor, + { + name: "slug", + showLinkIcon: false, + prefix: slugPrefix + } + ) }) + ] }), + editor && /* @__PURE__ */ jsx(HistoryButtons, { editor }), + !isMobile && /* @__PURE__ */ jsx(ModeButton, { editor }), + onSave && /* @__PURE__ */ jsx( + SaveButton, + { + onSave: () => { + onSave(editor.getHTML()); + }, + isLoading + } + ), + saveButton + ] }), + /* @__PURE__ */ jsx( + ArticleBodyEditorMenubar, + { + editor, + size: "sm", + imageDiskPrefix + } + ) + ] }) + ] }); +} +function SaveButton({ onSave, isLoading }) { + const form = useFormContext(); + const title = form.watch("title"); + return /* @__PURE__ */ jsx( + Button, + { + variant: "flat", + size: "sm", + color: "primary", + className: "min-w-90", + disabled: isLoading || !title, + onClick: () => onSave(), + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ); +} +function FormSlugEditor({ name, ...other }) { + const { + field: { onChange, onBlur, value = "", ref } + } = useController({ + name + }); + const manuallyChanged = useRef(false); + const { watch, setValue } = useFormContext(); + useEffect(() => { + const subscription = watch((formVal, { name: fieldName }) => { + if (fieldName === "title" && !manuallyChanged.current) { + setValue("slug", formVal.title); + } + }); + return () => subscription.unsubscribe(); + }, [watch, setValue]); + return /* @__PURE__ */ jsx( + SlugEditor, + { + className: clsx(!value && "invisible"), + onChange: (e) => { + manuallyChanged.current = true; + onChange(e); + }, + onInputBlur: onBlur, + value, + inputRef: ref, + ...other + } + ); +} +function useUpdateNewsArticle() { + const { articleId } = useParams(); + return useMutation({ + mutationFn: (payload) => updateArticle(articleId, payload), + onError: (err) => showHttpErrorToast(err), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["news"] }); + toast(message("Article updated")); + } + }); +} +function updateArticle(articleId, payload) { + return apiClient.put(`news/${articleId}`, payload).then((r) => r.data); +} +const ArticleBodyEditor$3 = React.lazy( + () => import("./article-body-editor-9e195fab.mjs") +); +function EditNewsArticlePage() { + const query = useNewsArticle("newsArticlePage"); + return query.data ? /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(PageMetaTags, { query }), + /* @__PURE__ */ jsx(PageContent$1, { article: query.data.article }) + ] }) : /* @__PURE__ */ jsx("div", { className: "relative h-full w-full", children: /* @__PURE__ */ jsx(PageStatus, { query }) }); +} +function PageContent$1({ article }) { + const navigate = useNavigate$1(); + const updateArticle2 = useUpdateNewsArticle(); + const form = useForm({ + defaultValues: { + title: article.title, + slug: article.slug, + body: article.body, + image: article.image + } + }); + const handleSave = (editorContent) => { + updateArticle2.mutate( + { + ...form.getValues(), + body: editorContent + }, + { + onSuccess: () => navigate("../..", { relative: "path" }) + } + ); + }; + return /* @__PURE__ */ jsx(Suspense, { fallback: /* @__PURE__ */ jsx(FullPageLoader, {}), children: /* @__PURE__ */ jsx(ArticleBodyEditor$3, { initialContent: article.body, children: (content, editor) => /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsxs(FormProvider, { ...form, children: [ + /* @__PURE__ */ jsx( + ArticleEditorStickyHeader, + { + editor, + backLink: "../..", + slugPrefix: "news", + isLoading: updateArticle2.isPending, + onSave: handleSave + } + ), + /* @__PURE__ */ jsxs("div", { className: "mx-20", children: [ + /* @__PURE__ */ jsx( + FormImageSelector, + { + className: "mx-auto mb-32 max-w-[655px]", + showEditButtonOnHover: true, + variant: "square", + name: "image", + diskPrefix: "news_images" + } + ), + /* @__PURE__ */ jsxs("div", { className: "prose mx-auto flex-auto dark:prose-invert", children: [ + /* @__PURE__ */ jsx(ArticleEditorTitle, {}), + content + ] }) + ] }) + ] }) }) }) }); +} +function useCreatNewsArticle() { + return useMutation({ + mutationFn: (payload) => createArticle(payload), + onError: (err) => showHttpErrorToast(err), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["news"] }); + toast(message("Article created")); + } + }); +} +function createArticle(payload) { + return apiClient.post(`news`, payload).then((r) => r.data); +} +const ArticleBodyEditor$2 = React.lazy( + () => import("./article-body-editor-9e195fab.mjs") +); +function CreateNewsArticlePage() { + const navigate = useNavigate$1(); + const createArticle2 = useCreatNewsArticle(); + const form = useForm({}); + const handleSave = (editorContent) => { + createArticle2.mutate( + { + ...form.getValues(), + body: editorContent + }, + { + onSuccess: () => navigate("..", { relative: "path" }) + } + ); + }; + return /* @__PURE__ */ jsx(Suspense, { fallback: /* @__PURE__ */ jsx(FullPageLoader, {}), children: /* @__PURE__ */ jsx(ArticleBodyEditor$2, { children: (content, editor) => /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsxs(FormProvider, { ...form, children: [ + /* @__PURE__ */ jsx( + ArticleEditorStickyHeader, + { + editor, + backLink: "..", + isLoading: createArticle2.isPending, + onSave: handleSave + } + ), + /* @__PURE__ */ jsxs("div", { className: "mx-20", children: [ + /* @__PURE__ */ jsx( + FormImageSelector, + { + className: "mx-auto mb-32 max-w-[655px]", + showEditButtonOnHover: true, + variant: "square", + name: "image", + diskPrefix: "news_images" + } + ), + /* @__PURE__ */ jsxs("div", { className: "prose mx-auto flex-auto dark:prose-invert", children: [ + /* @__PURE__ */ jsx(ArticleEditorTitle, {}), + content + ] }) + ] }) + ] }) }) }) }); +} +function useCreateTitleTag(form, type) { + return useMutation({ + mutationFn: (props) => createNewTag(props, type), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("title-tags") + }); + }, + onError: (err) => onFormQueryError(err, form) + }); +} +function createNewTag(payload, type) { + return apiClient.post(`title-tags/${type}`, payload).then((r) => r.data); +} +function CreateTitleTagDialog({ type }) { + const displayName = type.replace("_", " "); + const { formId, close } = useDialogContext(); + const form = useForm(); + const addTag = useCreateTitleTag(form, type); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Add :name", values: { name: displayName } }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs( + Form$1, + { + id: formId, + form, + onSubmit: async (values) => { + await addTag.mutate(values, { + onSuccess: () => { + toast(message(":name created", { values: { name: displayName } })); + close(); + } + }); + }, + children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Name" }), + description: /* @__PURE__ */ jsx( + Trans, + { + message: "Unique :name identifier.", + values: { name: displayName } + } + ), + className: "mb-20", + required: true, + autoFocus: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "display_name", + label: /* @__PURE__ */ jsx(Trans, { message: "Display name" }), + description: /* @__PURE__ */ jsx( + Trans, + { + message: "User friendly :name name.", + values: { name: displayName } + } + ), + className: "mb-20" + } + ) + ] + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: () => close(), children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx( + Button, + { + form: formId, + type: "submit", + variant: "flat", + color: "primary", + disabled: addTag.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Create" }) + } + ) + ] }) + ] }); +} +function useUpdateTitleTag(form, tag) { + return useMutation({ + mutationFn: (payload) => updateTag(payload, tag), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("title-tags") + }); + }, + onError: (err) => onFormQueryError(err, form) + }); +} +function updateTag(payload, tag) { + return apiClient.put(`title-tags/${tag.model_type}/${tag.id}`, payload).then((r) => r.data); +} +function UpdateTitleTagDialog({ tag }) { + const displayName = tag.model_type.replace("_", " "); + const { formId, close } = useDialogContext(); + const form = useForm({ + defaultValues: { + name: tag.name, + display_name: tag.display_name + } + }); + const updateTag2 = useUpdateTitleTag(form, tag); + return /* @__PURE__ */ jsxs(Dialog, { children: [ + /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(Trans, { message: "Add :name", values: { name: displayName } }) }), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs( + Form$1, + { + id: formId, + form, + onSubmit: async (values) => { + updateTag2.mutate(values, { + onSuccess: () => { + toast(message(":name updated", { values: { name: displayName } })); + close(); + } + }); + }, + children: [ + /* @__PURE__ */ jsx( + FormTextField, + { + name: "name", + label: /* @__PURE__ */ jsx(Trans, { message: "Name" }), + description: /* @__PURE__ */ jsx( + Trans, + { + message: "Unique :name identifier.", + values: { name: displayName } + } + ), + className: "mb-20", + required: true, + autoFocus: true + } + ), + /* @__PURE__ */ jsx( + FormTextField, + { + name: "display_name", + label: /* @__PURE__ */ jsx(Trans, { message: "Display name" }), + description: /* @__PURE__ */ jsx( + Trans, + { + message: "User friendly :name name.", + values: { name: displayName } + } + ), + className: "mb-20" + } + ) + ] + } + ) }), + /* @__PURE__ */ jsxs(DialogFooter, { children: [ + /* @__PURE__ */ jsx(Button, { onClick: () => close(), children: /* @__PURE__ */ jsx(Trans, { message: "Cancel" }) }), + /* @__PURE__ */ jsx( + Button, + { + form: formId, + type: "submit", + variant: "flat", + color: "primary", + disabled: updateTag2.isPending, + children: /* @__PURE__ */ jsx(Trans, { message: "Save" }) + } + ) + ] }) + ] }); +} +const TitleTagsDatatableFilters = [ + createdAtFilter({ + description: message("Date item was created") + }), + updatedAtFilter({ + description: message("Date item was last updated") + }) +]; +const columnConfig = [ + { + key: "name", + allowsSorting: true, + visibleInMode: "all", + width: "flex-3 min-w-200", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Name" }), + body: (tag) => tag.name + }, + { + key: "display_name", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Display name" }), + body: (tag) => tag.display_name + }, + { + key: "updated_at", + allowsSorting: true, + width: "w-100", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (tag) => /* @__PURE__ */ jsx(FormattedDate, { date: tag.updated_at }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + align: "end", + width: "w-42 flex-shrink-0", + visibleInMode: "all", + body: (tag) => /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(IconButton, { size: "md", className: "text-muted", children: /* @__PURE__ */ jsx(EditIcon, {}) }), + /* @__PURE__ */ jsx(UpdateTitleTagDialog, { tag }) + ] }) + } +]; +function TitleTagsDatatablePage({ type }) { + const displayType = `${type.replace("_", " ")}s`; + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: `title-tags/${type}`, + title: /* @__PURE__ */ jsx(Trans, { message: displayType }), + columns: columnConfig, + filters: TitleTagsDatatableFilters, + actions: /* @__PURE__ */ jsx(Actions$4, { type }), + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: softwareEngineerSvg, + title: /* @__PURE__ */ jsx( + Trans, + { + message: "No :name have been created yet", + values: { name: displayType } + } + ), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching :name", values: { name: displayType } }) + } + ) + } + ); +} +function Actions$4({ type }) { + return /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(DataTableAddItemButton, { children: /* @__PURE__ */ jsx( + Trans, + { + message: "Add new :name", + values: { name: type.replace("_", " ") } + } + ) }), + /* @__PURE__ */ jsx(CreateTitleTagDialog, { type }) + ] }) }); +} +const ListsDatatableColumns = [ + { + key: "name", + allowsSorting: true, + width: "flex-3", + visibleInMode: "all", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Name" }), + body: (list) => { + return /* @__PURE__ */ jsx( + "a", + { + className: "outline-none hover:underline focus-visible:underline", + href: `lists/${list.id}`, + target: "_blank", + rel: "noreferrer", + children: list.name + } + ); + } + }, + { + key: "user_id", + allowsSorting: true, + width: "flex-2 min-w-140", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Owner" }), + body: (list) => list.user && /* @__PURE__ */ jsx( + NameWithAvatar, + { + image: list.user.avatar, + label: list.user.display_name, + description: list.user.email + } + ) + }, + { + key: "items_count", + width: "w-96", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Items" }), + body: (list) => list.items_count && /* @__PURE__ */ jsx(FormattedNumber, { value: list.items_count }) + }, + { + key: "public", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Public" }), + width: "w-96", + body: (list) => list.public ? /* @__PURE__ */ jsx(CheckIcon, { className: "text-positive" }) : /* @__PURE__ */ jsx(CloseIcon, { className: "text-danger" }) + }, + { + key: "content_type", + allowsSorting: false, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Content type" }), + body: (list) => /* @__PURE__ */ jsx("span", { className: "capitalize", children: list.config.contentModel ? /* @__PURE__ */ jsx(Trans, { message: list.config.contentModel }) : void 0 }) + }, + { + key: "layout", + allowsSorting: false, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Layout" }), + body: (list) => /* @__PURE__ */ jsx("span", { className: "capitalize", children: list.config.layout ? /* @__PURE__ */ jsx(Trans, { message: list.config.layout }) : void 0 }) + }, + { + key: "updated_at", + allowsSorting: true, + maxWidth: "max-w-100", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Last updated" }), + body: (list) => list.updated_at ? /* @__PURE__ */ jsx(FormattedDate, { date: list.updated_at }) : "" + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + visibleInMode: "all", + align: "end", + width: "w-42 flex-shrink-0", + body: (list) => /* @__PURE__ */ jsx(Link, { to: `${list.id}/edit`, className: "text-muted", children: /* @__PURE__ */ jsx(IconButton, { size: "md", children: /* @__PURE__ */ jsx(EditIcon, {}) }) }) + } +]; +function ListsDatatablePage() { + return /* @__PURE__ */ jsx( + DataTablePage, + { + endpoint: "lists", + title: /* @__PURE__ */ jsx(Trans, { message: "User lists" }), + columns: ListsDatatableColumns, + actions: /* @__PURE__ */ jsx(Actions$3, {}), + selectedActions: /* @__PURE__ */ jsx(DeleteSelectedItemsAction, {}), + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: todoImage, + title: /* @__PURE__ */ jsx(Trans, { message: "No lists have been created yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching lists" }) + } + ) + } + ); +} +function Actions$3() { + return /* @__PURE__ */ jsx(DataTableAddItemButton, { elementType: Link, to: "new", children: /* @__PURE__ */ jsx(Trans, { message: "Add new list" }) }); +} +const monthDayFormat = { + month: "short", + day: "2-digit" +}; +function ReportDateSelector({ + value, + onChange, + disabled, + compactOnMobile = true, + enableCompare = false, + granularity = "minute" +}) { + const isMobile = useIsMobileMediaQuery(); + return /* @__PURE__ */ jsxs( + DialogTrigger, + { + type: "popover", + onClose: (value2) => { + if (value2) { + onChange(value2); + } + }, + children: [ + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "chip", + endIcon: /* @__PURE__ */ jsx(DateRangeIcon, {}), + disabled, + children: /* @__PURE__ */ jsx( + FormattedDateTimeRange, + { + start: value.start, + end: value.end, + options: isMobile && compactOnMobile ? monthDayFormat : DateFormatPresets.short + } + ) + } + ), + /* @__PURE__ */ jsx( + DateSelectorDialog, + { + value, + enableCompare, + granularity + } + ) + ] + } + ); +} +function DateSelectorDialog({ + value, + enableCompare, + granularity +}) { + const isMobile = useIsMobileMediaQuery(); + const state = useDateRangePickerState({ + granularity, + defaultValue: { + start: value.start, + end: value.end, + preset: value.preset + }, + closeDialogOnSelection: false + }); + const compareHasInitialValue = !!value.compareStart && !!value.compareEnd; + const compareState = useDateRangePickerState({ + granularity, + defaultValue: compareHasInitialValue ? { + start: value.compareStart, + end: value.compareEnd, + preset: value.comparePreset + } : DateRangeComparePresets[0].getRangeValue(state.selectedValue) + }); + return /* @__PURE__ */ jsx( + DateRangeDialog, + { + state, + compareState: enableCompare ? compareState : void 0, + compareVisibleDefault: compareHasInitialValue, + showInlineDatePickerField: !isMobile + } + ); +} +function ButtonGroup({ + children, + color, + variant, + radius = "rounded-button", + size, + className, + value, + onChange, + multiple, + disabled +}) { + const isActive = (childValue) => { + if (value === void 0) + return false; + if (multiple) { + return value.includes(childValue); + } + return childValue === value; + }; + const toggleMultipleValue = (childValue) => { + const newValue = [...value]; + const childIndex = value.indexOf(childValue); + if (childIndex > -1) { + newValue.splice(childIndex, 1); + } else { + newValue.push(childValue); + } + return newValue; + }; + const buttons = React.Children.map(children, (button, i) => { + if (React.isValidElement(button)) { + const active = isActive(button.props.value); + const adjustedColor = active ? "primary" : color; + return React.cloneElement(button, { + color: active ? "primary" : color, + variant, + size, + radius: null, + disabled: button.props.disabled || disabled, + ...button.props, + onClick: (e) => { + if (button.props.onClick) { + button.props.onClick(e); + } + if (!onChange) + return; + if (multiple) { + onChange == null ? void 0 : onChange(toggleMultipleValue(button.props.value)); + } else { + onChange == null ? void 0 : onChange(button.props.value); + } + }, + className: clsx( + button.props.className, + // borders are hidden via negative margin, make sure both are visible for active item + active ? "z-20" : "z-10", + getStyle(i, children, radius, adjustedColor) + ) + }); + } + }); + return /* @__PURE__ */ jsx("div", { className: clsx(radius, "isolate inline-flex", className), children: buttons }); +} +function getStyle(i, children, radius, color) { + if (i === 0) { + return clsx( + radius, + "rounded-tr-none rounded-br-none", + !color && "border-r-transparent disabled:border-r-transparent" + ); + } + if (i === children.length - 1) { + return clsx(radius, "rounded-tl-none rounded-bl-none -ml-1"); + } + return clsx( + "rounded-none -ml-1", + !color && "border-r-transparent disabled:border-r-transparent" + ); +} +const TrendingUpIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "m16 6 2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6h-6z" }), + "TrendingUpOutlined" +); +const TrendingDownIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "m16 18 2.29-2.29-4.88-4.88-4 4L2 7.41 3.41 6l6 6 4-4 6.3 6.29L22 12v6h-6z" }), + "TrendingDownOutlined" +); +function ChartLayout(props) { + const { + title, + description, + children, + className, + contentIsFlex = true, + contentClassName, + contentRef, + minHeight = "min-h-440" + } = props; + return /* @__PURE__ */ jsxs( + "div", + { + className: clsx( + "rounded-panel flex h-full flex-auto flex-col border bg", + minHeight, + className + ), + children: [ + /* @__PURE__ */ jsxs("div", { className: "flex flex-shrink-0 items-center justify-between p-14 text-xs", children: [ + /* @__PURE__ */ jsx("div", { className: "text-sm font-semibold", children: title }), + description && /* @__PURE__ */ jsx("div", { className: "text-muted", children: description }) + ] }), + /* @__PURE__ */ jsx( + "div", + { + ref: contentRef, + className: clsx( + "relative p-14", + contentIsFlex && "flex flex-auto items-center justify-center", + contentClassName + ), + children + } + ) + ] + } + ); +} +function ChartLoadingIndicator() { + return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-10 text-sm absolute mx-auto", children: [ + /* @__PURE__ */ jsx(ProgressCircle, { isIndeterminate: true, size: "sm" }), + /* @__PURE__ */ jsx(Trans, { message: "Chart loading" }) + ] }); +} +const LazyChart = lazy(() => import("./lazy-chart-9300132f.mjs")); +function BaseChart(props) { + const { title, description, className, contentRef, isLoading } = props; + return /* @__PURE__ */ jsx( + ChartLayout, + { + title, + description, + className, + contentRef, + children: /* @__PURE__ */ jsxs(Suspense, { fallback: /* @__PURE__ */ jsx(ChartLoadingIndicator, {}), children: [ + /* @__PURE__ */ jsx(LazyChart, { ...props }), + isLoading && /* @__PURE__ */ jsx(ChartLoadingIndicator, {}) + ] }) + } + ); +} +function formatReportData(report, { localeCode = "en", shareFirstDatasetLabels = true }) { + if (!report) + return { datasets: [] }; + const firstDatasetLabels = []; + return { + ...report, + datasets: report.datasets.map((dataset, datasetIndex) => { + const data = dataset.data.map((datasetItem, itemIndex) => { + let label; + if (datasetIndex === 0 || !shareFirstDatasetLabels) { + label = generateDatasetLabels( + datasetItem, + report.granularity, + localeCode + ); + firstDatasetLabels[itemIndex] = label; + } else { + label = firstDatasetLabels[itemIndex]; + } + return { + ...label, + value: datasetItem.value + }; + }); + return { ...dataset, data }; + }) + }; +} +function generateDatasetLabels(datum, granularity, locale) { + if (datum.label) { + return { label: datum.label }; + } + if (!datum.date) { + return { label: "" }; + } + return generateTimeLabels(datum, granularity, locale); +} +function generateTimeLabels({ date: isoDate, endDate: isoEndDate }, granularity = "day", locale) { + const date = parseAbsoluteToLocal(isoDate).toDate(); + const endDate = isoEndDate ? parseAbsoluteToLocal(isoEndDate).toDate() : null; + switch (granularity) { + case "minute": + return { + label: getFormatter(locale, { + second: "2-digit" + }).format(date), + tooltipTitle: getFormatter(locale, { + day: "2-digit", + hour: "numeric", + minute: "numeric", + second: "2-digit" + }).format(date) + }; + case "hour": + return { + label: getFormatter(locale, { + hour: "numeric", + minute: "numeric" + }).format(date), + tooltipTitle: getFormatter(locale, { + month: "short", + day: "2-digit", + hour: "numeric", + minute: "numeric" + }).format(date) + }; + case "day": + return { + label: getFormatter(locale, { + day: "2-digit", + weekday: "short" + }).format(date), + tooltipTitle: getFormatter(locale, { + day: "2-digit", + weekday: "short", + month: "short" + }).format(date) + }; + case "week": + return { + label: getFormatter(locale, { + month: "short", + day: "2-digit" + }).format(date), + tooltipTitle: getFormatter(locale, { + day: "2-digit", + month: "long", + year: "numeric" + }).formatRange(date, endDate) + }; + case "month": + return { + label: getFormatter(locale, { + month: "short", + year: "numeric" + }).format(date), + tooltipTitle: getFormatter(locale, { + month: "long", + year: "numeric" + }).format(date) + }; + case "year": + return { + label: getFormatter(locale, { + year: "numeric" + }).format(date), + tooltipTitle: getFormatter(locale, { + year: "numeric" + }).format(date) + }; + } +} +const getFormatter = memoize( + (locale, options) => { + return new DateFormatter(locale, options); + }, + { + equals: (a, b) => { + return shallowEqual(a, b); + }, + callTimeout: void 0 + } +); +const primaryColor = getBootstrapData().themes.all[0].values["--be-primary"]; +const ChartColors = [ + [ + `rgb(${primaryColor.replaceAll(" ", ",")})`, + `rgba(${primaryColor.replaceAll(" ", ",")},0.2)` + ], + ["rgb(255,112,67)", "rgb(255,112,67,0.2)"], + ["rgb(255,167,38)", "rgb(255,167,38,0.2)"], + ["rgb(141,110,99)", "rgb(141,110,99,0.2)"], + ["rgb(102,187,106)", "rgba(102,187,106,0.2)"], + ["rgb(92,107,192)", "rgb(92,107,192,0.2)"] +]; +const LineChartOptions = { + parsing: { + xAxisKey: "label", + yAxisKey: "value" + }, + datasets: { + line: { + fill: "origin", + tension: 0.1, + pointBorderWidth: 4, + pointHitRadius: 10 + } + }, + plugins: { + tooltip: { + intersect: false, + mode: "index" + } + } +}; +function LineChart({ data, className, ...props }) { + const { localeCode } = useSelectedLocale(); + const formattedData = useMemo(() => { + const formattedData2 = formatReportData(data, { localeCode }); + formattedData2.datasets = formattedData2.datasets.map((dataset, i) => ({ + ...dataset, + backgroundColor: ChartColors[i][1], + borderColor: ChartColors[i][0], + pointBackgroundColor: ChartColors[i][0] + })); + return formattedData2; + }, [data, localeCode]); + return /* @__PURE__ */ jsx( + BaseChart, + { + ...props, + className: clsx(className, "min-w-500"), + data: formattedData, + type: "line", + options: LineChartOptions + } + ); +} +const PolarAreaChartOptions = { + parsing: { + key: "value" + }, + plugins: { + tooltip: { + intersect: true + } + } +}; +function PolarAreaChart({ + data, + className, + ...props +}) { + const { localeCode } = useSelectedLocale(); + const formattedData = useMemo(() => { + var _a2; + const formattedData2 = formatReportData(data, { localeCode }); + formattedData2.labels = (_a2 = formattedData2.datasets[0]) == null ? void 0 : _a2.data.map((d) => d.label); + formattedData2.datasets = formattedData2.datasets.map((dataset, i) => ({ + ...dataset, + backgroundColor: ChartColors.map((c) => c[1]), + borderColor: ChartColors.map((c) => c[0]), + borderWidth: 2 + })); + return formattedData2; + }, [data, localeCode]); + return /* @__PURE__ */ jsx( + BaseChart, + { + type: "polarArea", + data: formattedData, + options: PolarAreaChartOptions, + className: clsx(className, "min-w-500"), + ...props + } + ); +} +function BarChart({ + data, + direction = "vertical", + individualBarColors = false, + className, + ...props +}) { + const { localeCode } = useSelectedLocale(); + const formattedData = useMemo(() => { + const formattedData2 = formatReportData(data, { localeCode }); + formattedData2.datasets = formattedData2.datasets.map((dataset, i) => ({ + ...dataset, + backgroundColor: individualBarColors ? ChartColors.map((c) => c[1]) : ChartColors[i][1], + borderColor: individualBarColors ? ChartColors.map((c) => c[0]) : ChartColors[i][0], + borderWidth: 2 + })); + return formattedData2; + }, [data, localeCode, individualBarColors]); + const isHorizontal = direction === "horizontal"; + const options = useMemo(() => { + return { + indexAxis: isHorizontal ? "y" : "x", + parsing: { + xAxisKey: isHorizontal ? "value" : "label", + yAxisKey: isHorizontal ? "label" : "value" + } + }; + }, [isHorizontal]); + return /* @__PURE__ */ jsx( + BaseChart, + { + type: "bar", + className: clsx(className, "min-w-500"), + data: formattedData, + options, + ...props + } + ); +} +const loaderUrl = "https://www.gstatic.com/charts/loader.js"; +function useGoogleGeoChart({ + placeholderRef, + data, + country, + onCountrySelected +}) { + const { trans } = useTrans(); + const { analytics } = useSettings(); + const apiKey = analytics == null ? void 0 : analytics.gchart_api_key; + const { selectedTheme } = useThemeSelector(); + const geoChartRef = useRef(); + const regionInteractivity = !!onCountrySelected && !country; + const drawGoogleChart = useCallback(() => { + var _a2, _b; + if (typeof google === "undefined") + return; + const seedData = data.map((location) => [location.label, location.value]); + seedData.unshift([ + country ? trans(message("City")) : trans(message("Country")), + trans(message("Clicks")) + ]); + const backgroundColor = `${themeValueToHex( + selectedTheme.values["--be-paper"] + )}`; + const chartColor = `${themeValueToHex( + selectedTheme.values["--be-primary"] + )}`; + const options = { + colorAxis: { colors: [chartColor] }, + backgroundColor, + region: country ? country.toUpperCase() : void 0, + resolution: country ? "provinces" : "countries", + displayMode: country ? "markers" : "regions", + enableRegionInteractivity: regionInteractivity + }; + if (!geoChartRef.current && placeholderRef.current && ((_a2 = google == null ? void 0 : google.visualization) == null ? void 0 : _a2.GeoChart)) { + geoChartRef.current = new google.visualization.GeoChart( + placeholderRef.current + ); + } + (_b = geoChartRef.current) == null ? void 0 : _b.draw( + google.visualization.arrayToDataTable(seedData), + options + ); + }, [ + selectedTheme, + data, + placeholderRef, + trans, + country, + regionInteractivity + ]); + const initGoogleGeoChart = useCallback(async () => { + if (lazyLoader.isLoadingOrLoaded(loaderUrl)) + return; + await lazyLoader.loadAsset(loaderUrl, { type: "js", id: "google-charts-js" }); + await google.charts.load("current", { + packages: ["geochart"], + mapsApiKey: apiKey + }); + drawGoogleChart(); + }, [apiKey, drawGoogleChart]); + useEffect(() => { + if (geoChartRef.current && onCountrySelected) { + google.visualization.events.addListener( + geoChartRef.current, + "regionClick", + (a) => onCountrySelected == null ? void 0 : onCountrySelected(a.region) + ); + } + return () => { + if (geoChartRef.current) { + google.visualization.events.removeAllListeners(geoChartRef.current); + } + }; + }, [onCountrySelected, geoChartRef.current]); + useEffect(() => { + initGoogleGeoChart(); + }, [initGoogleGeoChart]); + useEffect(() => { + drawGoogleChart(); + }, [selectedTheme, drawGoogleChart, data]); + return { drawGoogleChart }; +} +const FormattedCountryName = memo(({ code: countryCode }) => { + const { localeCode } = useSelectedLocale(); + const regionNames = new Intl.DisplayNames([localeCode], { type: "region" }); + let formattedName; + try { + formattedName = regionNames.of(countryCode.toUpperCase()); + } catch (e) { + } + return /* @__PURE__ */ jsx(Fragment, { children: formattedName }); +}); +function GeoChart({ + data: metricData, + isLoading, + onCountrySelected, + country, + ...layoutProps +}) { + const placeholderRef = useRef(null); + const regionInteractivity = !!onCountrySelected; + const initialData = metricData == null ? void 0 : metricData.datasets[0].data; + const data = useMemo(() => { + return initialData || []; + }, [initialData]); + useGoogleGeoChart({ placeholderRef, data, country, onCountrySelected }); + return /* @__PURE__ */ jsxs( + ChartLayout, + { + ...layoutProps, + className: "min-w-500", + title: /* @__PURE__ */ jsxs("div", { className: "flex items-center", children: [ + /* @__PURE__ */ jsx(Trans, { message: "Top Locations" }), + country ? /* @__PURE__ */ jsxs("span", { className: "pl-4", children: [ + "(", + /* @__PURE__ */ jsx(FormattedCountryName, { code: country }), + ")" + ] }) : null, + regionInteractivity && /* @__PURE__ */ jsx(InfoTrigger, {}) + ] }), + contentIsFlex: isLoading, + children: [ + isLoading && /* @__PURE__ */ jsx(ChartLoadingIndicator, {}), + /* @__PURE__ */ jsxs("div", { className: "flex gap-24", children: [ + /* @__PURE__ */ jsx( + "div", + { + ref: placeholderRef, + className: "flex-auto w-[480px] min-h-[340px]" + } + ), + /* @__PURE__ */ jsxs("div", { className: "w-[170px]", children: [ + /* @__PURE__ */ jsx("div", { className: "text-sm max-h-[340px] w-full flex-initial overflow-y-auto", children: data.map((location) => /* @__PURE__ */ jsxs( + "div", + { + className: clsx( + "flex items-center gap-4 mb-4", + regionInteractivity && "cursor-pointer hover:underline" + ), + role: regionInteractivity ? "button" : void 0, + onClick: () => { + onCountrySelected == null ? void 0 : onCountrySelected(location.code); + }, + children: [ + /* @__PURE__ */ jsx("div", { className: "max-w-110 whitespace-nowrap overflow-hidden overflow-ellipsis", children: location.label }), + /* @__PURE__ */ jsxs("div", { children: [ + "(", + location.percentage, + ")%" + ] }) + ] + }, + location.label + )) }), + country && /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + size: "xs", + className: "mt-14", + startIcon: /* @__PURE__ */ jsx(ArrowBackIcon, {}), + onClick: () => { + onCountrySelected == null ? void 0 : onCountrySelected(void 0); + }, + children: /* @__PURE__ */ jsx(Trans, { message: "Back to countries" }) + } + ) + ] }) + ] }) + ] + } + ); +} +function InfoTrigger() { + return /* @__PURE__ */ jsx( + InfoDialogTrigger, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Zooming in" }), + body: /* @__PURE__ */ jsx(Trans, { message: "Click on a country inside the map or country list to zoom in and see city data for that country." }) + } + ); +} +const AdminReportPageColGap = "gap-12 md:gap-16 mb-12 md:mb-16"; +const rowClassName = `flex flex-col md:flex-row md:items-center overflow-x-auto ${AdminReportPageColGap}`; +function VisitorsReportCharts({ + report, + isLoading +}) { + const totalViews = report == null ? void 0 : report.pageViews.total; + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsxs("div", { className: rowClassName, children: [ + /* @__PURE__ */ jsx( + LineChart, + { + isLoading, + className: "flex-auto", + data: report == null ? void 0 : report.pageViews, + title: /* @__PURE__ */ jsx(Trans, { message: "Pageviews" }), + description: totalViews ? /* @__PURE__ */ jsx( + Trans, + { + message: ":count total views", + values: { count: /* @__PURE__ */ jsx(FormattedNumber, { value: totalViews }) } + } + ) : null + } + ), + /* @__PURE__ */ jsx( + PolarAreaChart, + { + isLoading, + data: report == null ? void 0 : report.devices, + title: /* @__PURE__ */ jsx(Trans, { message: "Top devices" }) + } + ) + ] }), + /* @__PURE__ */ jsxs("div", { className: rowClassName, children: [ + /* @__PURE__ */ jsx( + BarChart, + { + isLoading, + data: report == null ? void 0 : report.browsers, + className: "flex-auto md:w-1/3", + direction: "horizontal", + individualBarColors: true, + hideLegend: true, + title: /* @__PURE__ */ jsx(Trans, { message: "Top browsers" }) + } + ), + /* @__PURE__ */ jsx( + GeoChart, + { + isLoading, + className: "flex-auto", + data: report == null ? void 0 : report.locations, + title: /* @__PURE__ */ jsx(Trans, { message: "Top locations" }) + } + ) + ] }) + ] }); +} +const TrendingFlatIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "m22 12-4-4v3H3v2h15v3l4-4z" }), + "TrendingFlatOutlined" +); +function AdminHeaderReport({ report, isLoading }) { + return /* @__PURE__ */ jsx( + "div", + { + className: `flex h-[97px] flex-shrink-0 items-center overflow-x-auto ${AdminReportPageColGap}`, + children: report == null ? void 0 : report.map((datum) => /* @__PURE__ */ jsx(ReportItem, { datum, isLoading }, datum.name)) + } + ); +} +function ReportItem({ datum, isLoading = false }) { + let icon; + if (isValidElement(datum.icon)) { + icon = cloneElement(datum.icon, { size: "lg" }); + } else { + const IconEl = createSvgIconFromTree(datum.icon); + icon = /* @__PURE__ */ jsx(IconEl, { size: "lg" }); + } + return /* @__PURE__ */ jsxs( + "div", + { + className: "rounded-panel flex h-full flex-auto items-center gap-18 whitespace-nowrap border p-20", + children: [ + /* @__PURE__ */ jsx("div", { className: "flex-shrink-0 rounded-lg bg-primary-light/20 p-10 text-primary", children: icon }), + /* @__PURE__ */ jsxs("div", { className: "flex-auto", children: [ + /* @__PURE__ */ jsx("div", { className: "flex items-center justify-between gap-20", children: /* @__PURE__ */ jsx("div", { className: "text-lg font-bold text-main", children: /* @__PURE__ */ jsx(AnimatePresence, { initial: false, mode: "wait", children: isLoading ? /* @__PURE__ */ jsx(m.div, { ...opacityAnimation, children: /* @__PURE__ */ jsx(Skeleton, { className: "min-w-24" }) }, "skeleton") : /* @__PURE__ */ jsx(m.div, { ...opacityAnimation, children: /* @__PURE__ */ jsx(FormattedValue, { datum }) }, "value") }) }) }), + /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-20", children: [ + /* @__PURE__ */ jsx("h2", { className: "text-sm text-muted", children: datum.name }), + (datum.percentageChange != null || datum.previousValue != null) && /* @__PURE__ */ jsx("div", { className: "flex items-center gap-10", children: /* @__PURE__ */ jsx(TrendingIndicator, { datum }) }) + ] }) + ] }) + ] + }, + datum.name + ); +} +function FormattedValue({ datum }) { + switch (datum.type) { + case "fileSize": + return /* @__PURE__ */ jsx(FormattedBytes, { bytes: datum.currentValue }); + case "percentage": + return /* @__PURE__ */ jsx( + FormattedNumber, + { + value: datum.currentValue, + style: "percent", + maximumFractionDigits: 1 + } + ); + default: + return /* @__PURE__ */ jsx(FormattedNumber, { value: datum.currentValue }); + } +} +function TrendingIndicator({ datum }) { + const percentage = calculatePercentage(datum); + let icon; + if (percentage > 0) { + icon = /* @__PURE__ */ jsx(TrendingUpIcon, { size: "md", className: "text-positive" }); + } else if (percentage === 0) { + icon = /* @__PURE__ */ jsx(TrendingFlatIcon, { className: "text-muted" }); + } else { + icon = /* @__PURE__ */ jsx(TrendingDownIcon, { className: "text-danger" }); + } + return /* @__PURE__ */ jsxs(Fragment, { children: [ + icon, + /* @__PURE__ */ jsxs("div", { className: "text-sm font-semibold text-muted", children: [ + percentage, + "%" + ] }) + ] }); +} +function calculatePercentage({ + percentageChange, + previousValue, + currentValue +}) { + if (percentageChange != null || previousValue == null || currentValue == null) { + return percentageChange ?? 0; + } + if (previousValue === 0) { + return 100; + } + return Math.round((currentValue - previousValue) / previousValue * 100); +} +const Endpoint = "admin/reports"; +function useAdminReport(payload = {}) { + return useQuery({ + queryKey: [Endpoint, payload], + queryFn: () => fetchAnalyticsReport(payload), + placeholderData: keepPreviousData + }); +} +function fetchAnalyticsReport({ + types, + dateRange +}) { + const params = {}; + if (types) { + params.types = types.join(","); + } + if (dateRange) { + params.startDate = dateRange.start.toAbsoluteString(); + params.endDate = dateRange.end.toAbsoluteString(); + params.timezone = dateRange.start.timeZone; + } + return apiClient.get(Endpoint, { params }).then((response) => response.data); +} +function MtdbAdminReportPage() { + const [dateRange, setDateRange] = useState(() => { + return DateRangePresets[2].getRangeValue(); + }); + const params = useParams(); + const channel = params["*"] || "plays"; + const title = channel === "visitors" ? /* @__PURE__ */ jsx(Trans, { message: "Visitors report" }) : /* @__PURE__ */ jsx(Trans, { message: "Plays report" }); + return /* @__PURE__ */ jsxs("div", { className: "min-h-full overflow-x-hidden p-12 md:p-24", children: [ + /* @__PURE__ */ jsxs("div", { className: "mb-24 items-center justify-between gap-24 md:flex", children: [ + /* @__PURE__ */ jsx(StaticPageTitle, { children: title }), + /* @__PURE__ */ jsx("h1", { className: "mb-24 text-3xl font-light md:mb-0", children: title }), + /* @__PURE__ */ jsxs("div", { className: "flex flex-shrink-0 items-center justify-between gap-10 md:gap-24", children: [ + /* @__PURE__ */ jsxs(ButtonGroup, { variant: "outline", value: channel, children: [ + /* @__PURE__ */ jsx(Button, { value: "plays", elementType: Link, to: "plays", children: /* @__PURE__ */ jsx(Trans, { message: "Plays" }) }), + /* @__PURE__ */ jsx(Button, { value: "visitors", elementType: Link, to: "visitors", children: /* @__PURE__ */ jsx(Trans, { message: "Visitors" }) }) + ] }), + /* @__PURE__ */ jsx(ReportDateSelector, { value: dateRange, onChange: setDateRange }) + ] }) + ] }), + /* @__PURE__ */ jsx(Header, { dateRange }), + /* @__PURE__ */ jsx(Outlet, { context: { dateRange, setDateRange } }) + ] }); +} +function Header({ dateRange }) { + const { data } = useAdminReport({ types: ["header"], dateRange }); + return /* @__PURE__ */ jsx(AdminHeaderReport, { report: data == null ? void 0 : data.headerReport }); +} +function InsightsReportRow({ children }) { + return /* @__PURE__ */ jsx("div", { className: "mb-12 flex flex-col gap-12 overflow-x-auto md:mb-18 md:gap-18 lg:flex-row lg:items-center", children }); +} +const endpoint = "reports/insights"; +function useInsightsReport(payload, options) { + return useQuery({ + queryKey: [endpoint, payload], + queryFn: () => fetchReport(endpoint, payload), + placeholderData: keepPreviousData, + enabled: options.isEnabled, + staleTime: Infinity + }); +} +function fetchReport(endpoint2, payload) { + var _a2; + const params = { + model: payload.model, + metrics: (_a2 = payload.metrics) == null ? void 0 : _a2.join(",") + }; + params.startDate = payload.dateRange.start.toAbsoluteString(); + params.endDate = payload.dateRange.end.toAbsoluteString(); + params.timezone = payload.dateRange.start.timeZone; + return apiClient.get(endpoint2, { params }).then((response) => response.data); +} +const InsightsChartsContext = React.createContext(null); +function useInsightsChartContext() { + return useContext(InsightsChartsContext); +} +function InsightsAsyncChart({ children, metric }) { + var _a2, _b; + const [isEnabled, setIsEnabled] = useState(false); + const { dateRange, model } = useInsightsChartContext(); + const query = useInsightsReport( + { metrics: [metric], model, dateRange }, + { isEnabled } + ); + const chart = typeof children === "function" ? children(query) : children; + const observerRef = useRef(); + const contentRef = useCallback((el) => { + var _a3; + if (el) { + const observer = new IntersectionObserver( + ([e]) => { + var _a4; + if (e.isIntersecting) { + setIsEnabled(true); + (_a4 = observerRef.current) == null ? void 0 : _a4.disconnect(); + observerRef.current = void 0; + } + }, + { threshold: 0.1 } + // if only header is visible, don't load + ); + observerRef.current = observer; + observer.observe(el); + } else if (observerRef.current) { + (_a3 = observerRef.current) == null ? void 0 : _a3.disconnect(); + } + }, []); + return cloneElement(chart, { + data: (_b = (_a2 = query.data) == null ? void 0 : _a2.report) == null ? void 0 : _b[metric], + isLoading: query.isLoading, + contentRef + }); +} +function InsightsPlaysChart() { + return /* @__PURE__ */ jsx(InsightsAsyncChart, { metric: "plays", children: ({ data }) => /* @__PURE__ */ jsx( + LineChart, + { + className: "flex-auto", + title: /* @__PURE__ */ jsx(Trans, { message: "Plays" }), + hideLegend: true, + description: /* @__PURE__ */ jsx( + Trans, + { + message: ":count total plays", + values: { + count: /* @__PURE__ */ jsx(FormattedNumber, { value: (data == null ? void 0 : data.report.plays.total) || 0 }) + } + } + ) + } + ) }); +} +function InsightsDevicesChart() { + return /* @__PURE__ */ jsx(InsightsAsyncChart, { metric: "devices", children: /* @__PURE__ */ jsx(PolarAreaChart, { title: /* @__PURE__ */ jsx(Trans, { message: "Top devices" }) }) }); +} +const InfoIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" }), + "InfoOutlined" +); +function TopModelsChartLayout({ data, isLoading, ...layoutProps }) { + const dataItems = (data == null ? void 0 : data.datasets[0].data) || []; + return /* @__PURE__ */ jsxs( + ChartLayout, + { + ...layoutProps, + className: "w-1/2 min-w-500 md:min-w-0", + contentIsFlex: isLoading, + contentClassName: "max-h-[370px] overflow-y-auto compact-scrollbar", + children: [ + isLoading && /* @__PURE__ */ jsx(ChartLoadingIndicator, {}), + dataItems.map((item) => /* @__PURE__ */ jsxs( + "div", + { + className: "mb-20 flex items-center justify-between gap-24 text-sm", + children: [ + /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-8", children: [ + /* @__PURE__ */ jsx( + Image, + { + model: item.model, + size: "w-42 h-42", + className: "flex-shrink-0 rounded" + } + ), + /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx("div", { className: "text-sm", children: /* @__PURE__ */ jsx(Name, { model: item.model }) }), + /* @__PURE__ */ jsx("div", { className: "text-xs text-muted", children: /* @__PURE__ */ jsx(Description, { model: item.model }) }) + ] }) + ] }), + /* @__PURE__ */ jsxs("div", { className: "flex flex-shrink-0 items-center gap-4", children: [ + /* @__PURE__ */ jsx(MediaPlayIcon, { className: "text-muted", size: "sm" }), + /* @__PURE__ */ jsx( + Trans, + { + message: ":count plays", + values: { count: /* @__PURE__ */ jsx(FormattedNumber, { value: item.value }) } + } + ) + ] }) + ] + }, + item.model.id + )), + !isLoading && !dataItems.length ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-8 text-muted", children: [ + /* @__PURE__ */ jsx(InfoIcon, { size: "sm" }), + /* @__PURE__ */ jsx(Trans, { message: "No plays in selected timeframe." }) + ] }) : null + ] + } + ); +} +function Image({ model, size, className }) { + const link = `/admin/${model.model_type}s/${model.id}`; + switch (model.model_type) { + case "title": + return /* @__PURE__ */ jsx( + TitlePoster, + { + title: model, + size, + srcSize: "sm", + className, + link: `/admin/titles/${model.id}/insights` + } + ); + case "season": + return /* @__PURE__ */ jsx( + SeasonPoster, + { + season: model, + title: model.title, + size, + srcSize: "sm", + className, + link: `/admin/titles/${model.title_id}/insights/seasons/${model.number}` + } + ); + case "episode": + return /* @__PURE__ */ jsx( + EpisodePoster, + { + episode: model, + title: model.title, + size, + srcSize: "sm", + className, + link: `/admin/titles/${model.title_id}/insights/seasons/${model.season_number}/episodes/${model.episode_number}` + } + ); + case "video": + return model.thumbnail ? /* @__PURE__ */ jsx(Link, { to: link, className: clsx(size, className), children: /* @__PURE__ */ jsx("img", { src: model.thumbnail, className: "h-full w-full", alt: "" }) }) : /* @__PURE__ */ jsx( + TitlePoster, + { + title: model.title, + size, + srcSize: "sm", + className, + link: `/admin/videos/${model.id}/insights` + } + ); + case "user": + return /* @__PURE__ */ jsx(UserAvatar, { user: model, size, className }); + } +} +function Name({ model }) { + switch (model.model_type) { + case "title": + return /* @__PURE__ */ jsx(TitleLink, { title: model, target: "_blank" }); + case "season": + return /* @__PURE__ */ jsx( + SeasonLink, + { + title: model.title, + seasonNumber: model.number, + target: "_blank" + } + ); + case "episode": + return /* @__PURE__ */ jsx( + EpisodeLink, + { + title: model.title, + episode: model, + seasonNumber: model.season_number, + target: "_blank" + } + ); + case "video": + return /* @__PURE__ */ jsx( + Link, + { + to: getWatchLink(model), + className: "hover:underline", + target: "_blank", + children: model.name + } + ); + case "user": + return model.id ? /* @__PURE__ */ jsx(UserProfileLink, { user: model, target: "_blank" }) : /* @__PURE__ */ jsx(Fragment, { children: model.display_name }); + } +} +function Description({ model }) { + switch (model.model_type) { + case "title": + return /* @__PURE__ */ jsx("span", { children: model.year }); + case "season": + return /* @__PURE__ */ jsx(TitleLink, { title: model.title, target: "_blank" }); + case "episode": + return /* @__PURE__ */ jsx(TitleLink, { title: model.title, target: "_blank" }); + case "user": + return null; + case "video": + return /* @__PURE__ */ jsx(TitleLink, { title: model.title, target: "_blank" }); + } +} +function InsightsSeriesChart() { + return /* @__PURE__ */ jsx(InsightsAsyncChart, { metric: "series", children: /* @__PURE__ */ jsx(TopModelsChartLayout, { title: /* @__PURE__ */ jsx(Trans, { message: "Most played series" }) }) }); +} +function InsightsMoviesChart() { + return /* @__PURE__ */ jsx(InsightsAsyncChart, { metric: "movies", children: /* @__PURE__ */ jsx(TopModelsChartLayout, { title: /* @__PURE__ */ jsx(Trans, { message: "Most played movies" }) }) }); +} +function InsightsVideosChart() { + return /* @__PURE__ */ jsx(InsightsAsyncChart, { metric: "videos", children: /* @__PURE__ */ jsx(TopModelsChartLayout, { title: /* @__PURE__ */ jsx(Trans, { message: "Most played videos" }) }) }); +} +function InsightsUsersChart() { + return /* @__PURE__ */ jsx(InsightsAsyncChart, { metric: "users", children: /* @__PURE__ */ jsx(TopModelsChartLayout, { title: /* @__PURE__ */ jsx(Trans, { message: "Top users" }) }) }); +} +function InsightsLocationsChart() { + return /* @__PURE__ */ jsx(InsightsAsyncChart, { metric: "locations", children: /* @__PURE__ */ jsx(GeoChart, { className: "flex-auto w-1/2 lg:max-w-[740px]" }) }); +} +function InsightsPlatformsChart() { + return /* @__PURE__ */ jsx(InsightsAsyncChart, { metric: "platforms", children: /* @__PURE__ */ jsx( + PolarAreaChart, + { + className: "max-w-500", + title: /* @__PURE__ */ jsx(Trans, { message: "Top platforms" }) + } + ) }); +} +function AdminInsightsReport() { + const { dateRange } = useOutletContext(); + const model = "video_play=0"; + return /* @__PURE__ */ jsxs(InsightsChartsContext.Provider, { value: { dateRange, model }, children: [ + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsPlaysChart, {}), + /* @__PURE__ */ jsx(InsightsDevicesChart, {}) + ] }), + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsSeriesChart, {}), + /* @__PURE__ */ jsx(InsightsMoviesChart, {}) + ] }), + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsVideosChart, {}), + /* @__PURE__ */ jsx(InsightsUsersChart, {}) + ] }), + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsLocationsChart, {}), + /* @__PURE__ */ jsx(InsightsPlatformsChart, {}) + ] }) + ] }); +} +function AdminVisitorsReport() { + const { dateRange } = useOutletContext(); + const { data, isLoading, isPlaceholderData } = useAdminReport({ + types: ["visitors"], + dateRange + }); + return /* @__PURE__ */ jsx( + VisitorsReportCharts, + { + isLoading: isLoading || isPlaceholderData, + report: data == null ? void 0 : data.visitorsReport + } + ); +} +function ModelInsightsPageLayout({ + children, + reportModel, + title, + name, + backLink +}) { + const [dateRange, setDateRange] = useState(() => { + return DateRangePresets[2].getRangeValue(); + }); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(StaticPageTitle, { children: /* @__PURE__ */ jsx(Trans, { message: ":name insights", values: { name } }) }), + /* @__PURE__ */ jsx("div", { className: "h-full flex flex-col", children: /* @__PURE__ */ jsx("div", { className: "flex-auto bg-cover relative", children: /* @__PURE__ */ jsx("div", { className: "min-h-full p-12 md:p-24 overflow-x-hidden max-w-[1600px] mx-auto flex flex-col", children: /* @__PURE__ */ jsxs("div", { className: "flex-auto", children: [ + /* @__PURE__ */ jsxs("div", { className: "md:flex items-center gap-12 h-48 mt-14 mb-38", children: [ + /* @__PURE__ */ jsx( + IconButton, + { + elementType: Link, + to: backLink || "../../", + relative: "path", + className: "text-muted", + children: /* @__PURE__ */ jsx(ArrowBackIcon, {}) + } + ), + title, + /* @__PURE__ */ jsx("div", { className: "ml-auto flex-shrink-0 flex items-center justify-between gap-10 md:gap-24", children: /* @__PURE__ */ jsx( + ReportDateSelector, + { + value: dateRange, + onChange: setDateRange + } + ) }) + ] }), + /* @__PURE__ */ jsx( + InsightsChartsContext.Provider, + { + value: { dateRange, model: reportModel }, + children + } + ) + ] }) }) }) }) + ] }); +} +function ModelInsightsPageTitle({ + image, + name, + description +}) { + return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-10", children: [ + cloneElement(image, { size: "w-48 h-48", className: "rounded" }), + /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsxs("h1", { className: "text-base whitespace-nowrap overflow-hidden overflow-ellipsis", children: [ + "“", + name, + "“ ", + /* @__PURE__ */ jsx(Trans, { message: "insights" }) + ] }), + description && /* @__PURE__ */ jsx("div", { className: "text-muted text-sm", children: description }) + ] }) + ] }); +} +function InsightsSeasonsChart() { + return /* @__PURE__ */ jsx(InsightsAsyncChart, { metric: "seasons", children: /* @__PURE__ */ jsx(TopModelsChartLayout, { title: /* @__PURE__ */ jsx(Trans, { message: "Most played seasons" }) }) }); +} +function InsightsEpisodesChart() { + return /* @__PURE__ */ jsx(InsightsAsyncChart, { metric: "episodes", children: /* @__PURE__ */ jsx(TopModelsChartLayout, { title: /* @__PURE__ */ jsx(Trans, { message: "Most played episodes" }) }) }); +} +function TitleInsightsPage() { + const { titleId } = useParams(); + const query = useTitle("title"); + return query.data ? /* @__PURE__ */ jsxs( + ModelInsightsPageLayout, + { + reportModel: `title=${titleId}`, + name: query.data.title.name, + title: /* @__PURE__ */ jsx( + ModelInsightsPageTitle, + { + image: /* @__PURE__ */ jsx(TitlePoster, { title: query.data.title, srcSize: "sm" }), + name: /* @__PURE__ */ jsx(TitleLink, { title: query.data.title }), + description: /* @__PURE__ */ jsx("span", { children: query.data.title.year }) + } + ), + children: [ + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsPlaysChart, {}), + /* @__PURE__ */ jsx(InsightsDevicesChart, {}) + ] }), + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsSeasonsChart, {}), + /* @__PURE__ */ jsx(InsightsEpisodesChart, {}) + ] }), + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsLocationsChart, {}), + /* @__PURE__ */ jsx(InsightsPlatformsChart, {}) + ] }) + ] + } + ) : /* @__PURE__ */ jsx(PageStatus, { query, loaderClassName: "absolute inset-0 m-auto" }); +} +function EpisodeInsightsPage() { + const query = useEpisode("episode"); + return query.data ? /* @__PURE__ */ jsxs( + ModelInsightsPageLayout, + { + reportModel: `episode=${query.data.episode.id}`, + name: query.data.episode.name, + backLink: "../../../../", + title: /* @__PURE__ */ jsx( + ModelInsightsPageTitle, + { + image: /* @__PURE__ */ jsx( + EpisodePoster, + { + episode: query.data.episode, + title: query.data.title, + srcSize: "sm" + } + ), + name: /* @__PURE__ */ jsx( + EpisodeLink, + { + episode: query.data.episode, + title: query.data.title, + seasonNumber: query.data.episode.season_number + } + ), + description: /* @__PURE__ */ jsx( + TitleLinkWithEpisodeNumber, + { + episode: query.data.episode, + title: query.data.title + } + ) + } + ), + children: [ + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsPlaysChart, {}), + /* @__PURE__ */ jsx(InsightsDevicesChart, {}) + ] }), + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsLocationsChart, {}), + /* @__PURE__ */ jsx(InsightsPlatformsChart, {}) + ] }) + ] + } + ) : /* @__PURE__ */ jsx(PageStatus, { query, loaderClassName: "absolute inset-0 m-auto" }); +} +function SeasonInsightsPage() { + const query = useSeason("season"); + return query.data ? /* @__PURE__ */ jsxs( + ModelInsightsPageLayout, + { + reportModel: `season=${query.data.season.id}`, + name: `Season ${query.data.season.number}`, + title: /* @__PURE__ */ jsx( + ModelInsightsPageTitle, + { + image: /* @__PURE__ */ jsx( + SeasonPoster, + { + season: query.data.season, + title: query.data.title, + srcSize: "sm" + } + ), + name: /* @__PURE__ */ jsx( + SeasonLink, + { + seasonNumber: query.data.season.number, + title: query.data.title + } + ), + description: /* @__PURE__ */ jsx(TitleLink, { title: query.data.title }) + } + ), + children: [ + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsPlaysChart, {}), + /* @__PURE__ */ jsx(InsightsDevicesChart, {}) + ] }), + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsLocationsChart, {}), + /* @__PURE__ */ jsx(InsightsPlatformsChart, {}) + ] }) + ] + } + ) : /* @__PURE__ */ jsx(PageStatus, { query, loaderClassName: "absolute inset-0 m-auto" }); +} +function VideoInsightsPage() { + var _a2; + const query = useVideo(); + const video = (_a2 = query.data) == null ? void 0 : _a2.video; + return video ? /* @__PURE__ */ jsxs( + ModelInsightsPageLayout, + { + reportModel: `video=${video.id}`, + name: video.name, + title: /* @__PURE__ */ jsx( + ModelInsightsPageTitle, + { + image: /* @__PURE__ */ jsx(VideoThumbnail, { video, srcSize: "sm" }), + name: /* @__PURE__ */ jsx( + Link, + { + to: getWatchLink(video), + className: "hover:underline", + target: "_blank", + children: video.name + } + ), + description: /* @__PURE__ */ jsx(TitleLink, { title: video.title }) + } + ), + children: [ + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsPlaysChart, {}), + /* @__PURE__ */ jsx(InsightsDevicesChart, {}) + ] }), + /* @__PURE__ */ jsxs(InsightsReportRow, { children: [ + /* @__PURE__ */ jsx(InsightsLocationsChart, {}), + /* @__PURE__ */ jsx(InsightsPlatformsChart, {}) + ] }) + ] + } + ) : /* @__PURE__ */ jsx(PageStatus, { query, loaderClassName: "absolute inset-0 m-auto" }); +} +const AppAdminRoutes = [ + // Reports + { + path: "/", + element: /* @__PURE__ */ jsx(MtdbAdminReportPage, {}), + children: [ + { index: true, element: /* @__PURE__ */ jsx(AdminInsightsReport, {}) }, + { path: "plays", element: /* @__PURE__ */ jsx(AdminInsightsReport, {}) }, + { path: "visitors", element: /* @__PURE__ */ jsx(AdminVisitorsReport, {}) } + ] + }, + // Channels + { + path: "channels", + element: /* @__PURE__ */ jsx(ChannelsDatatablePage, {}) + }, + { + path: "channels/new", + element: /* @__PURE__ */ jsx(CreateChannelPage, {}) + }, + { + path: "channels/:slugOrId/edit", + element: /* @__PURE__ */ jsx(EditChannelPage, {}) + }, + // User lists + { + path: "lists", + element: /* @__PURE__ */ jsx(ListsDatatablePage, {}) + }, + { + path: "lists/new", + element: /* @__PURE__ */ jsx(CreateUserListPage, {}) + }, + { + path: "lists/:slugOrId/edit", + element: /* @__PURE__ */ jsx(EditUserListPage, {}) + }, + // People + { + path: "people", + element: /* @__PURE__ */ jsx(PeopleDatatablePage, {}) + }, + { + path: "people/new", + element: /* @__PURE__ */ jsx(CreatePersonPage, {}) + }, + { + path: "people/:personId/edit", + element: /* @__PURE__ */ jsx(UpdatePersonPage, {}), + children: [ + { + index: true, + element: /* @__PURE__ */ jsx(Navigate, { to: "primary-facts", replace: true }) + }, + { + path: "primary-facts", + element: /* @__PURE__ */ jsx(PersonPrimaryFactsForm, {}) + }, + { + path: "credits", + element: /* @__PURE__ */ jsx(PersonCreditsEditor, {}) + } + ] + }, + // Titles + { + path: "titles", + element: /* @__PURE__ */ jsx(TitlesDatatablePage, {}) + }, + { + path: "titles/new", + element: /* @__PURE__ */ jsx(TitlePrimaryFactsForm, {}) + }, + { + path: "videos/:videoId/insights", + element: /* @__PURE__ */ jsx(VideoInsightsPage, {}) + }, + { + path: "titles/:titleId/insights", + element: /* @__PURE__ */ jsx(TitleInsightsPage, {}) + }, + { + path: "titles/:titleId/insights/seasons/:season", + element: /* @__PURE__ */ jsx(SeasonInsightsPage, {}) + }, + { + path: "titles/:titleId/insights/seasons/:season/episodes/:episode", + element: /* @__PURE__ */ jsx(EpisodeInsightsPage, {}) + }, + { + path: "titles/:titleId/edit", + element: /* @__PURE__ */ jsx(Navigate, { to: "primary-facts", replace: true }) + }, + { + path: "titles/:titleId/edit", + element: /* @__PURE__ */ jsx(EditTitlePage, {}), + children: [ + { + index: true, + element: /* @__PURE__ */ jsx(TitlePrimaryFactsForm, {}) + }, + { + path: "primary-facts", + element: /* @__PURE__ */ jsx(TitlePrimaryFactsForm, {}) + }, + { + path: "reviews", + element: /* @__PURE__ */ jsx(TitleReviewsEditor, {}) + }, + { + path: "comments", + element: /* @__PURE__ */ jsx(TitleCommentsEditor, {}) + }, + { + path: "images", + element: /* @__PURE__ */ jsx(TitleImagesEditor, {}) + }, + { + path: "genres", + element: /* @__PURE__ */ jsx(TitleTagsEditor, { type: GENRE_MODEL }) + }, + { + path: "keywords", + element: /* @__PURE__ */ jsx(TitleTagsEditor, { type: KEYWORD_MODEL }) + }, + { + path: "countries", + element: /* @__PURE__ */ jsx(TitleTagsEditor, { type: PRODUCTION_COUNTRY_MODEL }) + }, + { + path: "cast", + element: /* @__PURE__ */ jsx(TitleCastEditor, {}) + }, + { + path: "crew", + element: /* @__PURE__ */ jsx(TitleCrewEditor, {}) + }, + { + path: "videos", + element: /* @__PURE__ */ jsx(TitleVideosEditor, {}) + }, + { + path: "videos/seasons/:season", + element: /* @__PURE__ */ jsx(TitleVideosEditor, {}) + }, + { + path: "videos/seasons/:season/episodes/:episode", + element: /* @__PURE__ */ jsx(TitleVideosEditor, {}) + }, + // SEASONS + { + path: "seasons", + element: /* @__PURE__ */ jsx(TitleSeasonsEditor, {}) + }, + { + path: "seasons/:season", + children: [ + { + index: true, + element: /* @__PURE__ */ jsx(Navigate, { to: "episodes", replace: true }) + }, + { + path: "Episodes", + element: /* @__PURE__ */ jsx(SeasonEditorEpisodeList, {}) + }, + { + path: "cast", + element: /* @__PURE__ */ jsx(SeasonCastEditor, {}) + }, + { + path: "crew", + element: /* @__PURE__ */ jsx(SeasonCrewEditor, {}) + } + ] + }, + // EPISODES + { + path: "seasons/:season/episodes/new", + element: /* @__PURE__ */ jsx(EpisodePrimaryFactsForm, {}) + }, + { + path: "seasons/:season/episodes/:episode", + children: [ + { + index: true, + element: /* @__PURE__ */ jsx(Navigate, { to: "primary-facts", replace: true }) + }, + { + path: "primary-facts", + element: /* @__PURE__ */ jsx(EpisodePrimaryFactsForm, {}) + }, + { + path: "cast", + element: /* @__PURE__ */ jsx(EpisodeCastEditor, {}) + }, + { + path: "crew", + element: /* @__PURE__ */ jsx(EpisodeCrewEditor, {}) + } + ] + } + ] + }, + // Video editor with no season or episode selected + { + path: "titles/:titleId/edit/videos/new", + element: /* @__PURE__ */ jsx(CreateVideoPage, {}) + }, + { + path: "titles/:titleId/edit/videos/edit/:videoId", + element: /* @__PURE__ */ jsx(EditVideoPage, {}) + }, + // Video editor with season selected + { + path: "titles/:titleId/edit/videos/seasons/:season/new", + element: /* @__PURE__ */ jsx(CreateVideoPage, {}) + }, + { + path: "titles/:titleId/edit/videos/seasons/:season/edit/:videoId", + element: /* @__PURE__ */ jsx(EditVideoPage, {}) + }, + // Video editor with season and episode selected + { + path: "titles/:titleId/edit/videos/seasons/:season/episodes/:episode/new", + element: /* @__PURE__ */ jsx(CreateVideoPage, {}) + }, + { + path: "titles/:titleId/edit/videos/seasons/:season/episodes/:episode/edit/:videoId", + element: /* @__PURE__ */ jsx(EditVideoPage, {}) + }, + // News articles + { + path: "news", + element: /* @__PURE__ */ jsx(NewsDatatablePage, {}) + }, + { + path: "news/add", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "news.update", children: /* @__PURE__ */ jsx(CreateNewsArticlePage, {}) }) + }, + { + path: "news/:articleId/edit", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "news.update", children: /* @__PURE__ */ jsx(EditNewsArticlePage, {}) }) + }, + // Comments + { + path: "comments", + element: /* @__PURE__ */ jsx(CommentsDatatablePage, {}) + }, + // Reviews + { + path: "reviews", + element: /* @__PURE__ */ jsx(ReviewsDatatablePage, {}) + }, + // Videos + { + path: "videos", + element: /* @__PURE__ */ jsx(VideosDatatablePage, {}) + }, + { + path: "videos/new", + element: /* @__PURE__ */ jsx(CreateVideoPage, {}) + }, + { + path: "videos/:videoId/edit", + element: /* @__PURE__ */ jsx(EditVideoPage, {}) + }, + // Title tags + { + path: "keywords", + element: /* @__PURE__ */ jsx(TitleTagsDatatablePage, { type: KEYWORD_MODEL }) + }, + { + path: "genres", + element: /* @__PURE__ */ jsx(TitleTagsDatatablePage, { type: GENRE_MODEL }) + } +]; +function useUpdateCustomPage(endpoint2) { + const { pageId } = useParams(); + const finalEndpoint = `${endpoint2 || "custom-pages"}/${pageId}`; + return useMutation({ + mutationFn: (payload) => updatePage(payload, finalEndpoint), + onError: (err) => showHttpErrorToast(err), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["custom-pages"] }); + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey(finalEndpoint) + }); + toast(message("Page updated")); + } + }); +} +function updatePage(payload, endpoint2) { + return apiClient.put(`${endpoint2}`, payload).then((r) => r.data); +} +const ArticleBodyEditor$1 = React.lazy( + () => import("./article-body-editor-9e195fab.mjs") +); +function EditCustomPage() { + const query = useCustomPage(); + return query.data ? /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx(PageMetaTags, { query }), + /* @__PURE__ */ jsx(PageContent, { page: query.data.page }) + ] }) : /* @__PURE__ */ jsx("div", { className: "relative w-full h-full", children: /* @__PURE__ */ jsx(PageStatus, { query }) }); +} +function PageContent({ page }) { + const navigate = useNavigate$1(); + const crupdatePage = useUpdateCustomPage(); + const form = useForm({ + defaultValues: { + title: page.title, + slug: page.slug, + body: page.body + } + }); + const handleSave = (editorContent) => { + crupdatePage.mutate( + { + ...form.getValues(), + body: editorContent + }, + { + onSuccess: () => navigate("../..", { relative: "path" }) + } + ); + }; + return /* @__PURE__ */ jsx(Suspense, { fallback: /* @__PURE__ */ jsx(FullPageLoader, {}), children: /* @__PURE__ */ jsx(ArticleBodyEditor$1, { initialContent: page.body, children: (content, editor) => /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsxs(FormProvider, { ...form, children: [ + /* @__PURE__ */ jsx( + ArticleEditorStickyHeader, + { + editor, + backLink: "../..", + isLoading: crupdatePage.isPending, + onSave: handleSave + } + ), + /* @__PURE__ */ jsx("div", { className: "mx-20", children: /* @__PURE__ */ jsxs("div", { className: "prose dark:prose-invert mx-auto flex-auto", children: [ + /* @__PURE__ */ jsx(ArticleEditorTitle, {}), + content + ] }) }) + ] }) }) }) }); +} +function useCreateCustomPage(endpoint2) { + const finalEndpoint = endpoint2 || "custom-pages"; + return useMutation({ + mutationFn: (payload) => createPage(payload, finalEndpoint), + onError: (err) => showHttpErrorToast(err), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["custom-pages"] }); + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey(finalEndpoint) + }); + toast(message("Page created")); + } + }); +} +function createPage(payload, endpoint2) { + return apiClient.post(`${endpoint2}`, payload).then((r) => r.data); +} +const ArticleBodyEditor = React.lazy( + () => import("./article-body-editor-9e195fab.mjs") +); +function CreateCustomPage() { + const navigate = useNavigate$1(); + const createPage2 = useCreateCustomPage(); + const form = useForm(); + const handleSave = (editorContent) => { + createPage2.mutate( + { + ...form.getValues(), + body: editorContent + }, + { + onSuccess: () => navigate("../", { relative: "path" }) + } + ); + }; + return /* @__PURE__ */ jsx(Suspense, { fallback: /* @__PURE__ */ jsx(FullPageLoader, {}), children: /* @__PURE__ */ jsx(ArticleBodyEditor, { children: (content, editor) => /* @__PURE__ */ jsx(FileUploadProvider, { children: /* @__PURE__ */ jsxs(FormProvider, { ...form, children: [ + /* @__PURE__ */ jsx( + ArticleEditorStickyHeader, + { + editor, + isLoading: createPage2.isPending, + onSave: handleSave, + backLink: "../" + } + ), + /* @__PURE__ */ jsx("div", { className: "mx-20", children: /* @__PURE__ */ jsxs("div", { className: "prose dark:prose-invert mx-auto flex-auto", children: [ + /* @__PURE__ */ jsx(ArticleEditorTitle, {}), + content + ] }) }) + ] }) }) }) }); +} +const fontImage = "/assets/font-a5a81d1a.svg"; +function FontSelectorFilters({ + state: { filters, setFilters } +}) { + const { trans } = useTrans(); + return /* @__PURE__ */ jsxs("div", { className: "mb-24 items-center gap-24 @xs:flex", children: [ + /* @__PURE__ */ jsx( + TextField, + { + className: "mb-12 flex-auto @xs:mb-0", + value: filters.query, + onChange: (e) => { + setFilters({ + ...filters, + query: e.target.value + }); + }, + startAdornment: /* @__PURE__ */ jsx(SearchIcon, {}), + placeholder: trans(message("Search fonts")) + } + ), + /* @__PURE__ */ jsxs( + SelectForwardRef, + { + className: "flex-auto", + selectionMode: "single", + selectedValue: filters.category, + onSelectionChange: (value) => { + setFilters({ + ...filters, + category: value + }); + }, + children: [ + /* @__PURE__ */ jsx(Item, { value: "", children: /* @__PURE__ */ jsx(Trans, { message: "All categories" }) }), + /* @__PURE__ */ jsx(Item, { value: "serif", children: /* @__PURE__ */ jsx(Trans, { message: "Serif" }) }), + /* @__PURE__ */ jsx(Item, { value: "sans-serif", children: /* @__PURE__ */ jsx(Trans, { message: "Sans serif" }) }), + /* @__PURE__ */ jsx(Item, { value: "display", children: /* @__PURE__ */ jsx(Trans, { message: "Display" }) }), + /* @__PURE__ */ jsx(Item, { value: "handwriting", children: /* @__PURE__ */ jsx(Trans, { message: "Handwriting" }) }), + /* @__PURE__ */ jsx(Item, { value: "monospace", children: /* @__PURE__ */ jsx(Trans, { message: "Monospace" }) }) + ] + } + ) + ] }); +} +function useFilter(options) { + const collator = useCollator({ + usage: "search", + ...options + }); + return { + startsWith(string, substring) { + if (substring.length === 0) { + return true; + } + string = string.normalize("NFC"); + substring = substring.normalize("NFC"); + return collator.compare(string.slice(0, substring.length), substring) === 0; + }, + endsWith(string, substring) { + if (substring.length === 0) { + return true; + } + string = string.normalize("NFC"); + substring = substring.normalize("NFC"); + return collator.compare(string.slice(-substring.length), substring) === 0; + }, + contains(string, substring) { + if (substring.length === 0) { + return true; + } + string = string.normalize("NFC"); + substring = substring.normalize("NFC"); + let scan = 0; + const sliceLen = substring.length; + for (; scan + sliceLen <= string.length; scan++) { + const slice = string.slice(scan, scan + sliceLen); + if (collator.compare(substring, slice) === 0) { + return true; + } + } + return false; + } + }; +} +const BrowserSafeFonts = [ + { + label: message("System"), + family: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"', + category: "sans-serif" + }, + { family: "Impact, Charcoal, sans-serif", category: "sans-serif" }, + { family: "Arial, Helvetica Neue, Helvetica, sans-serif", category: "serif" }, + { family: '"Comic Sans MS", cursive, sans-serif', category: "Handwriting" }, + { family: "Century Gothic, sans-serif", category: "sans-serif" }, + { family: '"Courier New", Courier, monospace', category: "monospace" }, + { + family: '"Lucida Sans Unicode", "Lucida Grande", sans-serif', + category: "sans-serif" + }, + { family: '"Times New Roman", Times, serif', category: "serif" }, + { family: '"Lucida Console", Monaco, monospace', category: "monospace" }, + { family: '"Andele Mono", monospace, sans-serif', category: "sans-serif" }, + { family: "Verdana, Geneva, sans-serif", category: "sans-serif" }, + { + family: '"Helvetica Neue", Helvetica, Arial, sans-serif', + category: "sans-serif" + } +]; +function useFontSelectorState({ + value, + onChange +}) { + const { data, isLoading } = useValueLists(["googleFonts"]); + const [currentPage, setCurrentPage] = useState(0); + const [filters, setFilterState] = useState({ + query: "", + category: (value == null ? void 0 : value.category) ?? "" + }); + const { contains } = useFilter({ + sensitivity: "base" + }); + const setFilters = useCallback((filters2) => { + setFilterState(filters2); + setCurrentPage(0); + }, []); + const allFonts = useMemo(() => { + return BrowserSafeFonts.concat((data == null ? void 0 : data.googleFonts) ?? []); + }, [data == null ? void 0 : data.googleFonts]); + const filteredFonts = useMemo(() => { + return allFonts.filter((font) => { + var _a2; + return contains(font.family, filters.query) && (!filters.category || ((_a2 = font.category) == null ? void 0 : _a2.toLowerCase()) === filters.category.toLowerCase()); + }); + }, [allFonts, filters, contains]); + const pages2 = useMemo(() => { + return chunkArray(filteredFonts, 20); + }, [filteredFonts]); + const fonts = pages2[currentPage]; + useEffect(() => { + const id = "font-selector"; + if (fonts == null ? void 0 : fonts.length) { + loadFonts(fonts, { id }); + } + }, [fonts, currentPage]); + return { + fonts: fonts || [], + currentPage, + filteredFonts: filteredFonts || [], + setCurrentPage, + isLoading, + filters, + setFilters, + value, + onChange, + pages: pages2 + }; +} +function FontSelectorPagination({ + state: { currentPage = 0, setCurrentPage, filteredFonts, pages: pages2 } +}) { + const total = (filteredFonts == null ? void 0 : filteredFonts.length) || 0; + return /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-end gap-24 text-sm mt-30 pt-14 border-t", children: [ + total > 0 && /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx( + Trans, + { + message: ":from - :to of :total", + values: { + from: currentPage * 20 + 1, + to: Math.min((currentPage + 1) * 20, total), + total + } + } + ) }), + /* @__PURE__ */ jsxs("div", { className: "text-muted", children: [ + /* @__PURE__ */ jsx( + IconButton, + { + disabled: currentPage < 1, + onClick: () => { + setCurrentPage(Math.max(0, currentPage - 1)); + }, + children: /* @__PURE__ */ jsx(KeyboardArrowLeftIcon, {}) + } + ), + /* @__PURE__ */ jsx( + IconButton, + { + disabled: currentPage >= pages2.length - 1, + onClick: () => { + setCurrentPage(currentPage + 1); + }, + children: /* @__PURE__ */ jsx(KeyboardArrowRightIcon, {}) + } + ) + ] }) + ] }); +} +function FontSelector(props) { + const state = useFontSelectorState(props); + return /* @__PURE__ */ jsxs("div", { className: props.className, children: [ + /* @__PURE__ */ jsx(FontSelectorFilters, { state }), + /* @__PURE__ */ jsx(AnimatePresence, { initial: false, mode: "wait", children: /* @__PURE__ */ jsx(FontList, { state }) }), + /* @__PURE__ */ jsx(FontSelectorPagination, { state }) + ] }); +} +function FontList({ state }) { + const { isLoading, fonts } = state; + const gridClassName = "grid gap-24 grid-cols-[repeat(auto-fill,minmax(90px,1fr))] items-start"; + if (isLoading) { + return /* @__PURE__ */ jsx(FontListSkeleton, { className: gridClassName }); + } + if (!(fonts == null ? void 0 : fonts.length)) { + return /* @__PURE__ */ jsx( + IllustratedMessage, + { + className: "mt-60", + size: "sm", + image: /* @__PURE__ */ jsx(SvgImage, { src: fontImage }), + title: /* @__PURE__ */ jsx(Trans, { message: "No matching fonts" }), + description: /* @__PURE__ */ jsx(Trans, { message: "Try another search query or different category" }) + } + ); + } + return /* @__PURE__ */ jsx(m.div, { ...opacityAnimation, className: gridClassName, children: fonts == null ? void 0 : fonts.map((font) => /* @__PURE__ */ jsx(FontButton, { font, state }, font.family)) }, "font-list"); +} +function FontButton({ font, state: { value, onChange } }) { + const isActive = (value == null ? void 0 : value.family) === font.family; + const displayName = font.family.split(",")[0].replace(/"/g, ""); + return /* @__PURE__ */ jsxs( + ButtonBase, + { + display: "block", + onClick: () => { + onChange(font); + }, + children: [ + /* @__PURE__ */ jsx( + "span", + { + className: clsx( + "flex aspect-square items-center justify-center rounded-panel border text-4xl transition-bg-color hover:bg-hover md:text-5xl", + isActive && "ring-2 ring-primary ring-offset-2" + ), + children: /* @__PURE__ */ jsx("span", { style: { fontFamily: font.family }, children: "Aa" }) + } + ), + /* @__PURE__ */ jsx( + "span", + { + className: clsx( + "mt-6 block overflow-hidden overflow-ellipsis whitespace-nowrap text-sm", + isActive && "text-primary" + ), + children: font.label ? /* @__PURE__ */ jsx(Trans, { ...font.label }) : displayName + } + ) + ] + }, + font.family + ); +} +function FontListSkeleton({ className }) { + const items = Array.from(Array(20).keys()); + return /* @__PURE__ */ jsx(m.div, { ...opacityAnimation, className, children: items.map((index) => /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx("div", { className: "aspect-square", children: /* @__PURE__ */ jsx(Skeleton, { display: "block", variant: "rect" }) }), + /* @__PURE__ */ jsx(Skeleton, { className: "mt-6 text-sm" }) + ] }, index)) }, "font-list-skeleton"); +} +function ThemeFontPanel() { + const { setValue, watch } = useFormContext(); + const { themeIndex } = useParams(); + const key = `appearance.themes.all.${themeIndex}.font`; + return /* @__PURE__ */ jsx( + FontSelector, + { + value: watch(key), + onChange: (font) => { + setValue(key, font, { shouldDirty: true }); + appearanceState().preview.setThemeFont(font); + } + } + ); +} +const radiusMap = { + "rounded-none": { + label: message("Square"), + value: "0px" + }, + rounded: { + label: message("Small"), + value: "0.25rem" + }, + "rounded-md": { + label: message("Medium"), + value: "0.375rem" + }, + "rounded-lg": { + label: message("Large"), + value: "0.5rem" + }, + "rounded-xl": { + label: message("Larger"), + value: "0.75rem" + }, + "rounded-full": { + label: message("Pill"), + value: "9999px" + } +}; +function ThemeRadiusPanel() { + return /* @__PURE__ */ jsxs("div", { className: "space-y-24", children: [ + /* @__PURE__ */ jsx( + RadiusSelector, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Button rounding" }), + name: "button-radius" + } + ), + /* @__PURE__ */ jsx( + RadiusSelector, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Input rounding" }), + name: "input-radius" + } + ), + /* @__PURE__ */ jsx( + RadiusSelector, + { + label: /* @__PURE__ */ jsx(Trans, { message: "Panel rounding" }), + name: "panel-radius", + hidePill: true + } + ) + ] }); +} +function RadiusSelector({ label, name, hidePill }) { + const { themeIndex } = useParams(); + const { watch, setValue } = useFormContext(); + const formKey = `appearance.themes.all.${themeIndex}.values.--be-${name}`; + const currentValue = watch(formKey); + return /* @__PURE__ */ jsxs("div", { children: [ + /* @__PURE__ */ jsx("div", { className: "mb-10 text-sm font-semibold", children: label }), + /* @__PURE__ */ jsx("div", { className: "grid grid-cols-3 gap-10 text-sm", children: Object.entries(radiusMap).filter(([key]) => !hidePill || !key.includes("full")).map(([key, { label: label2, value }]) => /* @__PURE__ */ jsx( + PreviewButton, + { + radius: key, + isActive: value === currentValue, + onClick: () => { + setValue(formKey, value, { shouldDirty: true }); + }, + children: /* @__PURE__ */ jsx(Trans, { ...label2 }) + }, + key + )) }) + ] }); +} +function PreviewButton({ + radius, + children, + isActive, + onClick +}) { + return /* @__PURE__ */ jsx( + ButtonBase, + { + display: "block", + className: clsx( + "h-36 border-2 hover:bg-hover", + radius, + isActive && "border-primary" + ), + onClick, + children + } + ); +} +const tabs = ["schedule", "error", "outgoing-email"]; +function LogsPage() { + const { pathname } = useLocation(); + const activeTab = pathname.split("/").pop(); + const activeIndex = tabs.includes(activeTab) ? tabs.indexOf(activeTab) : 0; + return /* @__PURE__ */ jsxs(Tabs, { className: "p-12 md:p-24", selectedTab: activeIndex, children: [ + /* @__PURE__ */ jsxs(TabList, { children: [ + /* @__PURE__ */ jsx(Tab, { elementType: Link, to: "/admin/logs/schedule", replace: true, children: /* @__PURE__ */ jsx(Trans, { message: "Schedule" }) }), + /* @__PURE__ */ jsx(Tab, { elementType: Link, to: "/admin/logs/error", replace: true, children: /* @__PURE__ */ jsx(Trans, { message: "Error" }) }), + /* @__PURE__ */ jsx(Tab, { elementType: Link, to: "/admin/logs/outgoing-email", replace: true, children: /* @__PURE__ */ jsx(Trans, { message: "Email" }) }) + ] }), + /* @__PURE__ */ jsx(Outlet, {}) + ] }); +} +function useRerunScheduledCommand() { + const { trans } = useTrans(); + return useMutation({ + mutationFn: (payload) => rerunCommand(payload), + onSuccess: async (response, props) => { + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("logs/schedule") + }); + toast.positive(trans(message("Command reran"))); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function rerunCommand({ id }) { + return apiClient.post(`logs/schedule/rerun/${id}`).then((r) => r.data); +} +const EventRepeatIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M21 12V6c0-1.1-.9-2-2-2h-1V2h-2v2H8V2H6v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h7v-2H5V10h14v2h2zm-2-4H5V6h14v2zm-3.36 12c.43 1.45 1.77 2.5 3.36 2.5 1.93 0 3.5-1.57 3.5-3.5s-1.57-3.5-3.5-3.5c-.95 0-1.82.38-2.45 1H18V18h-4v-4h1.5v1.43c.9-.88 2.14-1.43 3.5-1.43 2.76 0 5 2.24 5 5s-2.24 5-5 5c-2.42 0-4.44-1.72-4.9-4h1.54z" }), + "EventRepeatOutlined" +); +const ScheduleDatatableColumns = [ + { + key: "command", + allowsSorting: true, + visibleInMode: "all", + width: "flex-3 min-w-200", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Name" }), + body: (item) => /* @__PURE__ */ jsx(NameWithAvatar, { label: item.command, description: item.output }) + }, + { + key: "ran_at", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Ran at" }), + body: (item) => /* @__PURE__ */ jsx(FormattedRelativeTime, { date: item.ran_at }) + }, + { + key: "duration", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Duration" }), + body: (item) => `${item.duration}ms` + }, + { + key: "exit_code", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Completed" }), + body: (item) => /* @__PURE__ */ jsx(BooleanIndicator, { value: item.exit_code === 0 }) + }, + { + key: "count_in_last_hour", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Runs recently" }), + body: (item) => /* @__PURE__ */ jsx(FormattedNumber, { value: item.count_in_last_hour }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + align: "end", + width: "w-42 flex-shrink-0", + visibleInMode: "all", + body: (item) => /* @__PURE__ */ jsx(RerunButton, { item }) + } +]; +function RerunButton({ item }) { + const rerunCommand2 = useRerunScheduledCommand(); + return /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Rerun now" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + className: "text-muted", + disabled: rerunCommand2.isPending, + onClick: () => { + rerunCommand2.mutate({ id: item.id }); + }, + children: /* @__PURE__ */ jsx(EventRepeatIcon, {}) + } + ) }); +} +const timelineImage = "/assets/timeline-172fbeee.svg"; +const DownloadIcon = createSvgIcon( + /* @__PURE__ */ jsx("path", { d: "M19 9h-4V3H9v6H5l7 7 7-7zm-8 2V5h2v6h1.17L12 13.17 9.83 11H11zm-6 7h14v2H5z" }), + "DownloadOutlined" +); +function ScheduleLogDatatable() { + return /* @__PURE__ */ jsx( + DataTablePage, + { + padding: "pt-12 md:pt-24", + endpoint: "logs/schedule", + title: /* @__PURE__ */ jsx(Trans, { message: "CRON schedule log" }), + columns: ScheduleDatatableColumns, + actions: /* @__PURE__ */ jsx(Actions$2, {}), + enableSelection: false, + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: timelineImage, + title: /* @__PURE__ */ jsx(Trans, { message: "No scheduled commands have ran yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching scheduled commands" }) + } + ) + } + ); +} +function Actions$2() { + return /* @__PURE__ */ jsx( + DataTableAddItemButton, + { + elementType: "a", + href: "api/v1/logs/schedule/download", + download: true, + icon: /* @__PURE__ */ jsx(DownloadIcon, {}), + children: /* @__PURE__ */ jsx(Trans, { message: "Download log" }) + } + ); +} +const bugFixingImage = "/assets/bug-fixing-bd601a66.svg"; +const ErrorLogDatatableColumns = [ + { + key: "message", + visibleInMode: "all", + width: "flex-3 min-w-200", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Message" }), + body: (item) => item.message + }, + { + key: "datetime", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Date" }), + body: (item) => /* @__PURE__ */ jsx(FormattedRelativeTime, { date: item.datetime }) + }, + { + key: "severity", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Severity" }), + body: (item) => { + return /* @__PURE__ */ jsxs( + "span", + { + className: clsx( + "flex items-center gap-6 text-xs capitalize", + item.level === "error" ? "text-danger" : "text-primary" + ), + children: [ + item.level === "error" ? /* @__PURE__ */ jsx(ErrorIcon, { size: "sm" }) : /* @__PURE__ */ jsx(InfoIcon, { size: "sm" }), + item.level + ] + } + ); + } + } +]; +function ErrorLogEntryDialog({ error }) { + return /* @__PURE__ */ jsxs(Dialog, { size: "fullscreen", children: [ + /* @__PURE__ */ jsx( + DialogHeader, + { + showDivider: true, + padding: "px-24 py-10", + actions: /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + size: "xs", + onClick: () => downloadLogItem(error), + children: /* @__PURE__ */ jsx(Trans, { message: "Download" }) + } + ), + children: /* @__PURE__ */ jsx(Trans, { message: "Error details" }) + } + ), + /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsx("pre", { className: "whitespace-pre-wrap break-words text-xs leading-5", children: error.exception }) }) + ] }); +} +function downloadLogItem(item) { + const el = document.createElement("a"); + el.setAttribute( + "href", + "data:text/plain;charset=utf-8," + encodeURIComponent(item.exception) + ); + el.setAttribute("download", `error-${item.id}.log`); + el.style.display = "none"; + document.body.appendChild(el); + el.click(); + document.body.removeChild(el); +} +function useDeleteErrorLog() { + const { trans } = useTrans(); + return useMutation({ + mutationFn: (payload) => deleteLogFile(payload), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: DatatableDataQueryKey("logs/error") + }); + toast(trans(message("Log file deleted"))); + }, + onError: (err) => showHttpErrorToast(err) + }); +} +function deleteLogFile({ identifier }) { + return apiClient.delete(`logs/error/${identifier}`).then((r) => r.data); +} +function ErrorLogDatatable() { + return /* @__PURE__ */ jsx( + DataTablePage, + { + padding: "pt-12 md:pt-24", + endpoint: "logs/error", + title: /* @__PURE__ */ jsx(Trans, { message: "Error log" }), + onRowAction: (item) => { + openDialog(ErrorLogEntryDialog, { error: item }); + }, + columns: ErrorLogDatatableColumns, + actions: /* @__PURE__ */ jsx(Actions$1, {}), + enableSelection: false, + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: bugFixingImage, + title: /* @__PURE__ */ jsx(Trans, { message: "No errors have been logged yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching error log entries" }) + } + ) + } + ); +} +function Actions$1() { + var _a2, _b, _c; + const { query, setParams } = useDataTable(); + const setOnce = useRef(false); + const [selectedFile, setSelectedFile] = useState(null); + useEffect(() => { + var _a3, _b2; + if (((_b2 = (_a3 = query.data) == null ? void 0 : _a3.files) == null ? void 0 : _b2.length) && !setOnce.current) { + setOnce.current = true; + const firstFile = query.data.files[0].identifier; + setSelectedFile(query.data.files[0].identifier); + if (firstFile !== query.data.selectedFile) { + setParams({ file: query.data.files[0].identifier }); + } + } + }, [query.data, setParams, setOnce]); + return /* @__PURE__ */ jsxs(Fragment, { children: [ + /* @__PURE__ */ jsx( + FileSelector, + { + files: ((_a2 = query.data) == null ? void 0 : _a2.files) ?? null, + selectedFile, + onSelected: (file) => { + setSelectedFile(file.identifier); + setParams({ file: file.identifier }); + } + } + ), + /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + color: "danger", + disabled: !selectedFile, + onClick: () => openDialog(ConfirmDeleteDialog, { identifier: selectedFile }), + children: /* @__PURE__ */ jsx(Trans, { message: "Delete" }) + } + ), + selectedFile && /* @__PURE__ */ jsx( + DataTableAddItemButton, + { + elementType: "a", + download: (_c = (_b = query.data) == null ? void 0 : _b.files.find((f) => f.identifier === selectedFile)) == null ? void 0 : _c.name, + href: `api/v1/logs/error/${selectedFile}/download`, + icon: /* @__PURE__ */ jsx(DownloadIcon, {}), + children: /* @__PURE__ */ jsx(Trans, { message: "Download log" }) + } + ) + ] }); +} +function FileSelector({ files, selectedFile, onSelected }) { + if (!files) { + return /* @__PURE__ */ jsx(Skeleton, { variant: "rect", className: "max-w-[210px]" }); + } + if (!files.length) { + return null; + } + return /* @__PURE__ */ jsx( + SelectForwardRef, + { + selectionMode: "single", + selectedValue: selectedFile, + size: "sm", + minWidth: "min-w-[210px]", + children: files == null ? void 0 : files.map((file) => /* @__PURE__ */ jsxs( + Item, + { + value: file.identifier, + onSelected: () => onSelected(file), + children: [ + file.name, + " (", + /* @__PURE__ */ jsx(FormattedBytes, { bytes: file.size }), + ")" + ] + }, + file.identifier + )) + } + ); +} +function ConfirmDeleteDialog({ identifier }) { + const deleteLog = useDeleteErrorLog(); + return /* @__PURE__ */ jsx( + ConfirmationDialog, + { + title: /* @__PURE__ */ jsx(Trans, { message: "Delete log file" }), + body: /* @__PURE__ */ jsx(Trans, { message: "Are you sure you want to delete this log file?" }), + confirm: /* @__PURE__ */ jsx(Trans, { message: "Delete" }), + onConfirm: () => deleteLog.mutate({ identifier }, { onSuccess: () => closeDialog() }), + isLoading: deleteLog.isPending, + isDanger: true + } + ); +} +const openedImage = "/assets/opened-4dded9dc.svg"; +function useOutgoingEmailLogItemWithMime(id) { + return useQuery({ + queryKey: ["logs/outgoing-email", id], + queryFn: () => fetchLogItem(id) + }); +} +function fetchLogItem(id) { + return apiClient.get(`logs/outgoing-email/${id}`).then((r) => r.data); +} +function OutgoingEmailLogEntryDialog({ logItemId }) { + const { data } = useOutgoingEmailLogItemWithMime(logItemId); + const { base_url } = useSettings(); + return /* @__PURE__ */ jsxs(Dialog, { size: "fullscreen", children: [ + /* @__PURE__ */ jsx( + DialogHeader, + { + showDivider: true, + padding: "px-24 py-10", + actions: /* @__PURE__ */ jsx( + Button, + { + variant: "outline", + size: "xs", + disabled: !data, + type: "button", + onClick: data ? () => downloadFileFromUrl( + `${base_url}/api/v1/logs/outgoing-email/${logItemId}/download` + ) : void 0, + children: /* @__PURE__ */ jsx(Trans, { message: "Download" }) + } + ), + children: /* @__PURE__ */ jsx(Trans, { message: "Email preview" }) + } + ), + /* @__PURE__ */ jsx(DialogBody, { children: data ? /* @__PURE__ */ jsx( + "iframe", + { + srcDoc: data.logItem.parsed_message.body.html, + className: "h-max w-full border-none", + onLoad: (e) => { + const iframe = e.target; + iframe.style.height = iframe.contentWindow.document.body.scrollHeight + "px"; + } + } + ) : /* @__PURE__ */ jsx("div", { className: "flex min-h-200 items-center justify-center", children: /* @__PURE__ */ jsx(ProgressCircle, { isIndeterminate: true }) }) }) + ] }); +} +const OutgoingEmailLogDatatableColumns = [ + { + key: "message_id", + allowsSorting: true, + visibleInMode: "all", + width: "flex-3 min-w-200", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Subject" }), + body: (item) => /* @__PURE__ */ jsx(NameWithAvatar, { label: item.subject, description: item.message_id }) + }, + { + key: "status", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Status" }), + body: (item) => { + switch (item.status) { + case "sent": + return /* @__PURE__ */ jsx(StatusChip, { color: "positive", children: /* @__PURE__ */ jsx(Trans, { message: "Sent" }) }); + case "not-sent": + return /* @__PURE__ */ jsx(StatusChip, { color: void 0, children: /* @__PURE__ */ jsx(Trans, { message: "Not sent" }) }); + case "error": + return /* @__PURE__ */ jsx(StatusChip, { color: "danger", children: /* @__PURE__ */ jsx(Trans, { message: "Error" }) }); + } + } + }, + { + key: "from", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "From" }), + body: (item) => item.from + }, + { + key: "to", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "To" }), + body: (item) => item.to + }, + { + key: "created_at", + allowsSorting: true, + header: () => /* @__PURE__ */ jsx(Trans, { message: "Date" }), + body: (item) => /* @__PURE__ */ jsx(FormattedRelativeTime, { date: item.created_at }) + }, + { + key: "actions", + header: () => /* @__PURE__ */ jsx(Trans, { message: "Actions" }), + hideHeader: true, + align: "end", + width: "w-42 flex-shrink-0", + visibleInMode: "all", + body: (item) => /* @__PURE__ */ jsx(PreviewEmailButton, { item }) + } +]; +function PreviewEmailButton({ item }) { + const rerunCommand2 = useRerunScheduledCommand(); + return /* @__PURE__ */ jsxs(DialogTrigger, { type: "modal", children: [ + /* @__PURE__ */ jsx(Tooltip, { label: /* @__PURE__ */ jsx(Trans, { message: "Preview" }), children: /* @__PURE__ */ jsx( + IconButton, + { + size: "md", + className: "text-muted", + disabled: rerunCommand2.isPending, + onClick: () => { + rerunCommand2.mutate({ id: item.id }); + }, + children: /* @__PURE__ */ jsx(VisibilityIcon, {}) + } + ) }), + /* @__PURE__ */ jsx(OutgoingEmailLogEntryDialog, { logItemId: item.id }) + ] }); +} +function StatusChip({ color, children }) { + return /* @__PURE__ */ jsx(Chip, { color, size: "xs", className: "w-max min-w-50 text-center", children }); +} +const OutgoingEmailLogDatatableFilters = [ + { + key: "status", + label: message("Status"), + description: message("Status of the outgoing email"), + defaultOperator: FilterOperator.eq, + control: { + type: FilterControlType.Select, + defaultValue: "01", + options: [ + { + key: "01", + label: message("Not sent"), + value: "no-sent" + }, + { + key: "02", + label: message("Sent"), + value: "sent" + }, + { + key: "03", + label: message("Error"), + value: "error" + } + ] + } + }, + createdAtFilter({ + description: message("Date email send was attempted") + }) +]; +function OutgoingEmailLogDatatable() { + return /* @__PURE__ */ jsx( + DataTablePage, + { + padding: "pt-12 md:pt-24", + endpoint: "logs/outgoing-email", + title: /* @__PURE__ */ jsx(Trans, { message: "Outgoing email" }), + columns: OutgoingEmailLogDatatableColumns, + filters: OutgoingEmailLogDatatableFilters, + actions: /* @__PURE__ */ jsx(Actions, {}), + enableSelection: false, + emptyStateMessage: /* @__PURE__ */ jsx( + DataTableEmptyStateMessage, + { + image: openedImage, + title: /* @__PURE__ */ jsx(Trans, { message: "No outgoing emails have been logged yet" }), + filteringTitle: /* @__PURE__ */ jsx(Trans, { message: "No matching emails" }) + } + ) + } + ); +} +function Actions() { + return /* @__PURE__ */ jsx( + DataTableAddItemButton, + { + elementType: "a", + href: "api/v1/logs/outgoing-email/download", + download: true, + icon: /* @__PURE__ */ jsx(DownloadIcon, {}), + children: /* @__PURE__ */ jsx(Trans, { message: "Download log" }) + } + ); +} +const ReportsPage = React.lazy(() => import("./admin-report-page-181c7a5d.mjs")); +const AdminRouteConfig = [ + { + path: "appearance", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "appearance.update", children: /* @__PURE__ */ jsx(AppearanceLayout, {}) }), + children: [ + { index: true, element: /* @__PURE__ */ jsx(SectionList, {}) }, + { path: "general", element: /* @__PURE__ */ jsx(GeneralSection, {}) }, + { path: "seo-settings", element: /* @__PURE__ */ jsx(SeoSection, {}) }, + { path: "custom-code", element: /* @__PURE__ */ jsx(CustomCodeSection, {}) }, + { path: "themes", element: /* @__PURE__ */ jsx(ThemeList, {}) }, + { path: "themes/:themeIndex", element: /* @__PURE__ */ jsx(ThemeEditor, {}) }, + { path: "themes/:themeIndex/font", element: /* @__PURE__ */ jsx(ThemeFontPanel, {}) }, + { path: "themes/:themeIndex/radius", element: /* @__PURE__ */ jsx(ThemeRadiusPanel, {}) }, + { path: "menus", element: /* @__PURE__ */ jsx(MenuList, {}) }, + { path: "menus/:menuIndex", element: /* @__PURE__ */ jsx(MenuEditor, {}) }, + { + path: "menus/:menuIndex/items/:menuItemIndex", + element: /* @__PURE__ */ jsx(MenuItemEditor, {}) + }, + ...Object.values(AppAppearanceConfig.sections).flatMap( + (s) => s.routes || [] + ) + ] + }, + { + path: "/", + element: /* @__PURE__ */ jsx(AdminLayout, {}), + children: [ + ...AppAdminRoutes, + // REPORT PAGE + { + path: "/", + element: /* @__PURE__ */ jsx(React.Suspense, { fallback: /* @__PURE__ */ jsx(FullPageLoader, { screen: true }), children: /* @__PURE__ */ jsx(ReportsPage, {}) }) + }, + // USERS + { + path: "users", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "users.update", children: /* @__PURE__ */ jsx(UserDatatable, {}) }) + }, + { + path: "users/new", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "users.update", children: /* @__PURE__ */ jsx(CreateUserPage, {}) }) + }, + { + path: "users/:userId/edit", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "users.update", children: /* @__PURE__ */ jsx(UpdateUserPage, {}) }) + }, + // ROLES + { + path: "roles", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "roles.update", children: /* @__PURE__ */ jsx(RolesIndexPage, {}) }) + }, + { + path: "roles/new", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "roles.update", children: /* @__PURE__ */ jsx(CreateRolePage, {}) }) + }, + { + path: "roles/:roleId/edit", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "roles.update", children: /* @__PURE__ */ jsx(EditRolePage, {}) }) + }, + // SUBSCRIPTIONS and PLANS + { + path: "subscriptions", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "subscriptions.update", children: /* @__PURE__ */ jsx(SubscriptionsIndexPage, {}) }) + }, + { + path: "plans", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "plans.update", children: /* @__PURE__ */ jsx(PlansIndexPage, {}) }) + }, + { + path: "plans/new", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "plans.update", children: /* @__PURE__ */ jsx(CreatePlanPage, {}) }) + }, + { + path: "plans/:productId/edit", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "plans.update", children: /* @__PURE__ */ jsx(EditPlanPage, {}) }) + }, + // CUSTOM PAGES + { + path: "custom-pages", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "custom_pages.update", children: /* @__PURE__ */ jsx(CustomPageDatablePage, {}) }) + }, + { + path: "custom-pages/new", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "custom_pages.update", children: /* @__PURE__ */ jsx(CreateCustomPage, {}) }) + }, + { + path: "custom-pages/:pageId/edit", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "custom_pages.update", children: /* @__PURE__ */ jsx(EditCustomPage, {}) }) + }, + // TAGS + { + path: "tags", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "tags.update", children: /* @__PURE__ */ jsx(TagIndexPage, {}) }) + }, + // LOCALIZATIONS + { + path: "localizations", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "localizations.update", children: /* @__PURE__ */ jsx(LocalizationIndex, {}) }) + }, + { + path: "localizations/:localeId/translate", + element: /* @__PURE__ */ jsx(TranslationManagementPage, {}) + }, + // FILE ENTRIES + { + path: "files", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "files.update", children: /* @__PURE__ */ jsx(FileEntryIndexPage, {}) }) + }, + // ADS + { + path: "ads", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "settings.update", children: /* @__PURE__ */ jsx(AdsPage, {}) }) + }, + // SETTINGS + { + path: "settings", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "settings.update", children: /* @__PURE__ */ jsx(SettingsLayout, {}) }), + children: [ + { index: true, element: /* @__PURE__ */ jsx(Navigate, { to: "general", replace: true }) }, + { path: "general", element: /* @__PURE__ */ jsx(GeneralSettings, {}) }, + { path: "subscriptions", element: /* @__PURE__ */ jsx(SubscriptionSettings, {}) }, + { path: "localization", element: /* @__PURE__ */ jsx(LocalizationSettings, {}) }, + { path: "authentication", element: /* @__PURE__ */ jsx(AuthenticationSettings, {}) }, + { path: "uploading", element: /* @__PURE__ */ jsx(UploadingSettings, {}) }, + { path: "outgoing-email", element: /* @__PURE__ */ jsx(OutgoingEmailSettings, {}) }, + { path: "cache", element: /* @__PURE__ */ jsx(CacheSettings, {}) }, + { path: "analytics", element: /* @__PURE__ */ jsx(ReportsSettings, {}) }, + { path: "logging", element: /* @__PURE__ */ jsx(LoggingSettings, {}) }, + { path: "queue", element: /* @__PURE__ */ jsx(QueueSettings, {}) }, + { path: "recaptcha", element: /* @__PURE__ */ jsx(RecaptchaSettings, {}) }, + { path: "gdpr", element: /* @__PURE__ */ jsx(GdprSettings, {}) }, + ...AppSettingsRoutes + ] + }, + // LOGS + { + path: "logs", + element: /* @__PURE__ */ jsx(AuthRoute, { permission: "logs.view", children: /* @__PURE__ */ jsx(LogsPage, {}) }), + children: [ + { index: true, element: /* @__PURE__ */ jsx(ScheduleLogDatatable, {}) }, + { path: "schedule", element: /* @__PURE__ */ jsx(ScheduleLogDatatable, {}) }, + { path: "error", element: /* @__PURE__ */ jsx(ErrorLogDatatable, {}) }, + { path: "outgoing-email", element: /* @__PURE__ */ jsx(OutgoingEmailLogDatatable, {}) } + ] + } + ] + }, + { path: "*", element: /* @__PURE__ */ jsx(NotFoundPage, {}) } +]; +function AdminRoutes() { + return useRoutes(AdminRouteConfig); +} +const adminRoutes = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ + __proto__: null, + default: AdminRoutes +}, Symbol.toStringTag, { value: "Module" })); +export { + useFilter as $, + AdminHeaderReport as A, + BarChartIcon as B, + CodeIcon as C, + DashboardIcon as D, + EventRepeatIcon as E, + FileDownloadIcon as F, + RedoIcon as G, + HomeIcon as H, + InfoIcon as I, + ReportIcon as J, + RestartAltIcon as K, + LinkIcon as L, + MenuOpenIcon as M, + NoteIcon as N, + SubtitlesIcon as O, + PauseIcon as P, + SyncIcon as Q, + ReportDateSelector as R, + SmartDisplayIcon as S, + TranslateIcon as T, + TrendingDownIcon as U, + VisitorsReportCharts as V, + TrendingFlatIcon as W, + TrendingUpIcon as X, + UndoIcon as Y, + VisibilityIcon as Z, + ZoomOutMapIcon as _, + DeleteIcon as a, + iconGridStyle as a0, + adminRoutes as a1, + DescriptionIcon as b, + DownloadIcon as c, + DragIndicatorIcon as d, + FormatAlignCenterIcon as e, + FormatAlignJustifyIcon as f, + FormatAlignLeftIcon as g, + FormatAlignRightIcon as h, + FormatBoldIcon as i, + FormatClearIcon as j, + FormatColorFillIcon as k, + FormatColorTextIcon as l, + FormatIndentDecreaseIcon as m, + FormatIndentIncreaseIcon as n, + FormatItalicIcon as o, + FormatListBulletedIcon as p, + FormatListNumberedIcon as q, + FormatUnderlinedIcon as r, + HorizontalRuleIcon as s, + PersonOffIcon as t, + useAdminReport as u, + PlayArrowIcon as v, + PriorityHighIcon as w, + PublicIcon as x, + PublishIcon as y, + RecentActorsIcon as z +}; +//# sourceMappingURL=admin-routes-06d8abf9.mjs.map diff --git a/bootstrap/ssr/assets/admin-routes-06d8abf9.mjs.map b/bootstrap/ssr/assets/admin-routes-06d8abf9.mjs.map new file mode 100755 index 0000000..333f9db --- /dev/null +++ b/bootstrap/ssr/assets/admin-routes-06d8abf9.mjs.map @@ -0,0 +1 @@ +{"version":3,"file":"admin-routes-06d8abf9.mjs","sources":["../../../common/resources/client/admin/admin-sidebar.tsx","../../../common/resources/client/ui/layout/dashboard-layout-context.ts","../../../common/resources/client/utils/hooks/use-block-body-overflow.ts","../../../common/resources/client/ui/layout/dashboard-layout.tsx","../../../common/resources/client/ui/layout/dashboard-content.tsx","../../../common/resources/client/ui/layout/dashboard-sidenav.tsx","../../../common/resources/client/icons/material/MenuOpen.tsx","../../../common/resources/client/ui/layout/dashboard-navbar.tsx","../../../common/resources/client/admin/use-admin-setup-alerts.ts","../../../common/resources/client/admin/admin-layout.tsx","../../../common/resources/client/datatable/filters/timestamp-filters.ts","../../../common/resources/client/admin/users/user-datatable-filters.ts","../../../common/resources/client/datatable/page/data-table-context.ts","../../../common/resources/client/datatable/data-table-pagination-footer.tsx","../../../common/resources/client/datatable/data-table-header.tsx","../../../common/resources/client/datatable/selected-state-datatable-header.tsx","../../../common/resources/client/datatable/data-table.tsx","../../../common/resources/client/datatable/page/data-table-page.tsx","../../../common/resources/client/datatable/requests/delete-selected-rows.ts","../../../common/resources/client/datatable/page/delete-selected-items-action.tsx","../../../common/resources/client/datatable/page/data-table-emty-state-message.tsx","../../../common/resources/client/admin/roles/team.svg","../../../common/resources/client/datatable/data-table-add-item-button.tsx","../../../common/resources/client/icons/material/FileDownload.tsx","../../../common/resources/client/datatable/requests/use-export-csv.ts","../../../common/resources/client/uploads/utils/download-file-from-url.ts","../../../common/resources/client/datatable/csv-export/csv-export-info-dialog.tsx","../../../common/resources/client/datatable/csv-export/data-table-export-csv-button.tsx","../../../common/resources/client/icons/material/PersonOff.tsx","../../../common/resources/client/admin/users/requests/use-ban-user.ts","../../../common/resources/client/ui/forms/input-field/date/date-picker/use-date-picker-state.ts","../../../common/resources/client/ui/forms/input-field/date/date-picker/date-picker.tsx","../../../common/resources/client/admin/users/ban-user-dialog.tsx","../../../common/resources/client/admin/users/requests/use-unban-user.ts","../../../common/resources/client/admin/users/requests/use-impersonate-user.ts","../../../common/resources/client/admin/users/user-datatable-columns.tsx","../../../common/resources/client/admin/users/user-datatable.tsx","../../../common/resources/client/utils/array/chunk-array.ts","../../../common/resources/client/admin/appearance/config/default-appearance-config.ts","../../../common/resources/client/admin/appearance/appearance-button.tsx","../../../common/resources/client/admin/appearance/sections/themes/color-icon.tsx","../../../common/resources/client/ui/color-picker/color-swatch.tsx","../../../common/resources/client/ui/color-picker/color-presets.ts","../../../common/resources/client/ui/color-picker/color-picker.tsx","../../../common/resources/client/ui/color-picker/color-picker-dialog.tsx","../../../resources/client/admin/appearance/sections/landing-page-section/landing-page-section-general.tsx","../../../common/resources/client/utils/string/uc-first.ts","../../../common/resources/client/auth/ui/permission-selector.tsx","../../../common/resources/client/admin/appearance/sections/menus/hooks/available-routes.ts","../../../common/resources/client/ui/icon-picker/icon-grid-style.ts","../../../common/resources/client/ui/icon-picker/icon-picker.tsx","../../../common/resources/client/ui/icon-picker/icon-picker-dialog.tsx","../../../common/resources/client/admin/menus/menu-item-form.tsx","../../../resources/client/admin/appearance/sections/landing-page-section/landing-page-section-action-buttons.tsx","../../../resources/client/admin/appearance/sections/landing-page-section/landing-page-section-primary-features.tsx","../../../resources/client/admin/appearance/sections/landing-page-section/landing-page-section-secondary-features.tsx","../../../resources/client/admin/appearance/app-appearance-config.tsx","../../../common/resources/client/admin/appearance/config/merged-appearance-config.ts","../../../common/resources/client/admin/appearance/appearance-store.ts","../../../common/resources/client/admin/appearance/requests/save-appearance-changes.ts","../../../common/resources/client/admin/appearance/requests/appearance-values.ts","../../../common/resources/client/admin/appearance/section-header.tsx","../../../common/resources/client/admin/appearance/appearance-layout.tsx","../../../common/resources/client/admin/appearance/sections/menus/menu-list.tsx","../../../common/resources/client/admin/appearance/sections/menus/add-menu-item-dialog.tsx","../../../common/resources/client/icons/material/DragIndicator.tsx","../../../common/resources/client/icons/material/Delete.tsx","../../../common/resources/client/admin/appearance/sections/menus/dropdown-menu.svg","../../../common/resources/client/admin/appearance/sections/menus/menu-editor.tsx","../../../common/resources/client/admin/appearance/sections/menus/menu-item-editor.tsx","../../../common/resources/client/admin/appearance/sections/general-section.tsx","../../../common/resources/client/utils/string/random-number.ts","../../../common/resources/client/admin/appearance/sections/themes/theme-list.tsx","../../../common/resources/client/ace-editor/ace-dialog.tsx","../../../common/resources/client/admin/appearance/sections/seo/use-seo-tags.ts","../../../common/resources/client/admin/appearance/sections/seo/use-update-seo-tags.ts","../../../common/resources/client/admin/appearance/sections/seo/seo-section.tsx","../../../common/resources/client/admin/appearance/sections/code/custom-code-section.tsx","../../../common/resources/client/admin/custom-pages/articles.svg","../../../common/resources/client/auth/user.ts","../../../common/resources/client/admin/custom-pages/custom-page-datatable-filters.tsx","../../../common/resources/client/admin/custom-pages/custom-page-datatable-columns.tsx","../../../common/resources/client/admin/custom-pages/custom-page-datable-page.tsx","../../../resources/client/admin/settings/app-settings-nav-config.ts","../../../common/resources/client/admin/settings/settings-nav-config.ts","../../../common/resources/client/admin/settings/settings-layout.tsx","../../../common/resources/client/admin/settings/requests/use-admin-settings.ts","../../../common/resources/client/admin/settings/generate-sitemap.ts","../../../common/resources/client/admin/settings/requests/update-admin-settings.ts","../../../common/resources/client/admin/settings/settings-panel.tsx","../../../common/resources/client/admin/settings/settings-separator.tsx","../../../common/resources/client/icons/material/Link.tsx","../../../common/resources/client/admin/settings/learn-more-link.tsx","../../../common/resources/client/admin/settings/pages/general-settings.tsx","../../../common/resources/client/ui/themes/utils/color-to-theme-value.ts","../../../common/resources/client/admin/appearance/sections/themes/theme-settings-dialog-trigger.tsx","../../../common/resources/client/icons/material/RestartAlt.tsx","../../../common/resources/client/admin/appearance/sections/themes/theme-more-options-button.tsx","../../../common/resources/client/admin/appearance/sections/themes/navbar-color-picker.tsx","../../../common/resources/client/ui/themes/utils/theme-value-to-hex.ts","../../../common/resources/client/admin/appearance/sections/themes/theme-editor.tsx","../../../common/resources/client/admin/settings/json-chip-field.tsx","../../../resources/client/admin/settings/video-settings.tsx","../../../common/resources/client/ui/tabs/tab-panels.tsx","../../../resources/client/admin/settings/content-settings/content-settings-general-panel.tsx","../../../common/resources/client/admin/settings/settings-error-group.tsx","../../../resources/client/admin/settings/content-settings/content-settings-automation-panel.tsx","../../../resources/client/admin/settings/content-settings/content-settings-title-page-panel.tsx","../../../resources/client/admin/settings/content-settings/content-settings.tsx","../../../common/resources/client/admin/settings/pages/search-settings/requests/use-search-models.ts","../../../common/resources/client/admin/settings/pages/search-settings/requests/use-import-search-models.ts","../../../common/resources/client/admin/settings/pages/search-settings/search-settings.tsx","../../../resources/client/admin/settings/app-settings-routes.tsx","../../../common/resources/client/admin/settings/pages/subscription-settings.tsx","../../../common/resources/client/admin/settings/pages/localization-settings.tsx","../../../common/resources/client/admin/settings/pages/authentication-settings.tsx","../../../common/resources/client/admin/settings/pages/uploading-settings/max-server-upload-size.ts","../../../common/resources/client/uploads/utils/space-units.ts","../../../common/resources/client/uploads/utils/convert-to-bytes.ts","../../../common/resources/client/ui/forms/input-field/file-size-field.tsx","../../../common/resources/client/admin/settings/pages/uploading-settings/use-upload-s3-cors.ts","../../../common/resources/client/admin/settings/pages/uploading-settings/dropbox-form/use-generate-dropbox-refresh-token.ts","../../../common/resources/client/admin/settings/pages/uploading-settings/dropbox-form/dropbox-form.tsx","../../../common/resources/client/admin/settings/pages/uploading-settings/uploading-settings.tsx","../../../common/resources/client/admin/settings/pages/mail-settings/mailgun-credentials.tsx","../../../common/resources/client/admin/settings/pages/mail-settings/smtp-credentials.tsx","../../../common/resources/client/admin/settings/pages/mail-settings/ses-credentials.tsx","../../../common/resources/client/admin/settings/pages/mail-settings/postmark-credentials.tsx","../../../common/resources/client/admin/settings/pages/mail-settings/gmail-icon.tsx","../../../common/resources/client/admin/settings/pages/mail-settings/connect-gmail-panel.tsx","../../../common/resources/client/admin/settings/pages/mail-settings/outgoing-mail-group.tsx","../../../common/resources/client/admin/settings/pages/mail-settings/outgoing-email-settings.tsx","../../../common/resources/client/admin/settings/pages/cache-settings/clear-cache.ts","../../../common/resources/client/admin/settings/pages/cache-settings/cache-settings.tsx","../../../common/resources/client/admin/settings/pages/logging-settings.tsx","../../../common/resources/client/admin/settings/pages/queue-settings.tsx","../../../common/resources/client/admin/settings/pages/recaptcha-settings.tsx","../../../common/resources/client/ui/forms/input-field/file-field.tsx","../../../common/resources/client/admin/settings/pages/reports-settings.tsx","../../../common/resources/client/admin/users/requests/update-user.ts","../../../common/resources/client/admin/users/crupdate-user-form.tsx","../../../common/resources/client/icons/material/Report.tsx","../../../common/resources/client/admin/users/update-user-page.tsx","../../../common/resources/client/admin/users/requests/create-user.ts","../../../common/resources/client/admin/users/create-user-page.tsx","../../../common/resources/client/icons/material/Translate.tsx","../../../common/resources/client/admin/translations/use-locale-with-lines.ts","../../../common/resources/client/admin/translations/update-localization.ts","../../../common/resources/client/admin/translations/update-localization-dialog.tsx","../../../common/resources/client/admin/translations/create-localization.ts","../../../common/resources/client/admin/translations/create-localization-dialog.tsx","../../../common/resources/client/admin/translations/around-the-world.svg","../../../common/resources/client/admin/translations/use-upload-translation-file.ts","../../../common/resources/client/admin/translations/localization-index.tsx","../../../common/resources/client/admin/translations/new-translation-dialog.tsx","../../../common/resources/client/admin/translations/translation-management-page.tsx","../../../common/resources/client/admin/ads/ads-page.tsx","../../../common/resources/client/admin/appearance/section-list.tsx","../../../common/resources/client/admin/roles/role-index-page-filters.ts","../../../common/resources/client/admin/roles/roles-index-page.tsx","../../../common/resources/client/admin/roles/requests/use-role.ts","../../../common/resources/client/admin/roles/requests/use-update-role.ts","../../../common/resources/client/admin/roles/crupdate-role-page/crupdate-role-settings-panel.tsx","../../../common/resources/client/users/select-user-dialog.tsx","../../../common/resources/client/admin/roles/requests/use-remove-users-from-role.ts","../../../common/resources/client/admin/roles/requests/use-add-users-to-role.ts","../../../common/resources/client/admin/roles/crupdate-role-page/edit-role-page-users-panel.tsx","../../../common/resources/client/admin/roles/crupdate-role-page/edit-role-page.tsx","../../../common/resources/client/admin/roles/requests/user-create-role.ts","../../../common/resources/client/admin/roles/crupdate-role-page/create-role-page.tsx","../../../common/resources/client/admin/tags/tag-index-page-filters.ts","../../../common/resources/client/admin/tags/software-engineer.svg","../../../common/resources/client/admin/tags/crupdate-tag-form.tsx","../../../common/resources/client/admin/tags/requests/use-create-new-tag.ts","../../../common/resources/client/admin/tags/create-tag-dialog.tsx","../../../common/resources/client/admin/tags/requests/use-update-tag.ts","../../../common/resources/client/admin/tags/update-tag-dialog.tsx","../../../common/resources/client/admin/tags/tag-index-page.tsx","../../../common/resources/client/uploads/formatted-bytes.tsx","../../../common/resources/client/icons/material/Visibility.tsx","../../../common/resources/client/admin/file-entry/upload.svg","../../../common/resources/client/uploads/hooks/file-entry-urls.ts","../../../common/resources/client/uploads/preview/file-preview-context.ts","../../../common/resources/client/uploads/preview/file-preview/default-file-preview.tsx","../../../common/resources/client/uploads/preview/file-preview/image-file-preview.tsx","../../../common/resources/client/uploads/preview/file-preview/text-file-preview.tsx","../../../common/resources/client/uploads/preview/file-preview/video-file-preview.tsx","../../../common/resources/client/uploads/preview/file-preview/audio-file-preview.tsx","../../../common/resources/client/uploads/preview/file-preview/pdf-file-preview.tsx","../../../common/resources/client/uploads/preview/file-preview/word-document-file-preview.tsx","../../../common/resources/client/uploads/preview/available-previews.ts","../../../common/resources/client/uploads/file-type-icon/icons/default-file-icon.tsx","../../../common/resources/client/uploads/file-type-icon/icons/audio-file-icon.tsx","../../../common/resources/client/uploads/file-type-icon/icons/video-file-icon.tsx","../../../common/resources/client/uploads/file-type-icon/icons/text-file-icon.tsx","../../../common/resources/client/uploads/file-type-icon/icons/pdf-file-icon.tsx","../../../common/resources/client/uploads/file-type-icon/icons/archive-file-icon.tsx","../../../common/resources/client/uploads/file-type-icon/icons/folder-file-icon.tsx","../../../common/resources/client/uploads/file-type-icon/icons/image-file-icon.tsx","../../../common/resources/client/uploads/file-type-icon/icons/power-point-file-icon.tsx","../../../common/resources/client/uploads/file-type-icon/icons/word-file-icon.tsx","../../../common/resources/client/uploads/file-type-icon/icons/spreadsheet-file-icon.tsx","../../../common/resources/client/uploads/file-type-icon/icons/shared-folder-file-icon.tsx","../../../common/resources/client/uploads/file-type-icon/file-type-icon.tsx","../../../common/resources/client/uploads/file-type-icon/file-thumbnail.tsx","../../../common/resources/client/uploads/preview/file-preview-container.tsx","../../../common/resources/client/uploads/preview/file-preview-dialog.tsx","../../../common/resources/client/admin/file-entry/file-entry-index-filters.ts","../../../common/resources/client/admin/file-entry/file-entry-index-page.tsx","../../../common/resources/client/admin/subscriptions/subscription-index-page-filters.ts","../../../common/resources/client/admin/subscriptions/subscriptions.svg","../../../common/resources/client/admin/subscriptions/requests/use-update-subscription.ts","../../../common/resources/client/admin/subscriptions/crupdate-subscription-form.tsx","../../../common/resources/client/admin/subscriptions/update-subscription-dialog.tsx","../../../common/resources/client/admin/subscriptions/requests/use-create-subscription.ts","../../../common/resources/client/admin/subscriptions/create-subscription-dialog.tsx","../../../common/resources/client/icons/material/Pause.tsx","../../../common/resources/client/icons/material/PlayArrow.tsx","../../../common/resources/client/admin/subscriptions/subscriptions-index-page.tsx","../../../common/resources/client/icons/material/Sync.tsx","../../../common/resources/client/admin/plans/requests/use-sync-products.ts","../../../common/resources/client/admin/plans/requests/use-delete-product.ts","../../../common/resources/client/admin/plans/plans-index-page-filters.ts","../../../common/resources/client/admin/plans/plans-index-page.tsx","../../../common/resources/client/admin/plans/requests/use-product.ts","../../../common/resources/client/admin/plans/crupdate-plan-page/billing-period-presets.ts","../../../common/resources/client/admin/plans/crupdate-plan-page/price-form.tsx","../../../common/resources/client/admin/plans/crupdate-plan-page/crupdate-plan-form.tsx","../../../common/resources/client/admin/plans/requests/use-update-product.ts","../../../common/resources/client/admin/plans/crupdate-plan-page/edit-plan-page.tsx","../../../common/resources/client/admin/plans/requests/use-create-product.ts","../../../common/resources/client/admin/plans/crupdate-plan-page/create-plan-page.tsx","../../../common/resources/client/admin/settings/pages/gdpr-settings.tsx","../../../common/resources/client/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger-icon.tsx","../../../common/resources/client/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger.tsx","../../../common/resources/client/icons/material/Home.tsx","../../../common/resources/client/admin/channels/channels-datatable-columns.tsx","../../../common/resources/client/admin/channels/requests/use-apply-channel-preset.ts","../../../common/resources/client/admin/channels/channels-docs-link.tsx","../../../common/resources/client/admin/channels/channels-datatable-page.tsx","../../../common/resources/client/admin/channels/requests/use-update-channel.ts","../../../common/resources/client/admin/channels/channel-editor/edit-channel-page-layout.tsx","../../../common/resources/client/icons/material/Description.tsx","../../../common/resources/client/ui/slug-editor.tsx","../../../common/resources/client/admin/channels/channel-editor/controls/channel-name-field.tsx","../../../common/resources/client/admin/channels/channel-editor/controls/content-type-field.tsx","../../../common/resources/client/admin/channels/channel-editor/controls/content-auto-update-field.tsx","../../../resources/client/admin/channels/channel-auto-update-field.tsx","../../../resources/client/titles/models/keyword.ts","../../../resources/client/admin/channels/channel-restriction-field.tsx","../../../common/resources/client/icons/material/Dashboard.tsx","../../../common/resources/client/admin/channels/channel-editor/controls/content-layout-fields.tsx","../../../common/resources/client/admin/channels/channel-editor/controls/channel-pagination-type-field.tsx","../../../common/resources/client/icons/material/Public.tsx","../../../resources/client/admin/channels/channel-seo-fields.tsx","../../../resources/client/admin/channels/edit-channel-page.tsx","../../../common/resources/client/admin/channels/requests/use-create-channel.ts","../../../common/resources/client/admin/channels/channel-editor/create-channel-page-layout.tsx","../../../resources/client/admin/channels/create-channel-page.tsx","../../../resources/client/admin/news/news-datatable-filters.ts","../../../resources/client/admin/news/online-articles.svg","../../../resources/client/admin/news/requests/use-delete-news-article.ts","../../../resources/client/admin/news/news-datatable-columns.tsx","../../../common/resources/client/icons/material/Publish.tsx","../../../resources/client/admin/news/requests/use-import-news-articles.ts","../../../resources/client/admin/news/news-datatable-page.tsx","../../../common/resources/client/comments/comments-datatable-page/delete-comments-button.tsx","../../../common/resources/client/comments/requests/use-update-comment.ts","../../../common/resources/client/comments/requests/use-restore-comments.ts","../../../common/resources/client/comments/comments-datatable-page/restore-comments-button.tsx","../../../common/resources/client/comments/comments-datatable-page/comment-datatable-item.tsx","../../../common/resources/client/comments/comments-datatable-page/public-discussion.svg","../../../common/resources/client/comments/comments-datatable-page/comments-datatable-filters.ts","../../../common/resources/client/comments/comments-datatable-page/comments-datatable-page.tsx","../../../resources/client/admin/reviews/reviews.svg","../../../resources/client/admin/reviews/delete-reviews-button.tsx","../../../resources/client/admin/reviews/requests/use-update-review.ts","../../../resources/client/admin/reviews/review-datatable-item.tsx","../../../resources/client/admin/reviews/reviews-datatable-filters.tsx","../../../resources/client/admin/reviews/reviews-datatable-page.tsx","../../../resources/client/admin/videos/video-files.svg","../../../common/resources/client/datatable/column-templates/boolean-indicator.tsx","../../../common/resources/client/icons/material/BarChart.tsx","../../../resources/client/admin/videos/videos-datatable-columns.tsx","../../../resources/client/admin/reviews/title-filter/title-filter-control.tsx","../../../resources/client/titles/requests/use-titles-autocomplete.ts","../../../resources/client/titles/title-select.tsx","../../../resources/client/admin/reviews/title-filter/title-filter-panel.tsx","../../../resources/client/admin/videos/videos-datatable-filters.tsx","../../../resources/client/admin/videos/videos-datatable-page.tsx","../../../resources/client/admin/videos/requests/use-create-video.ts","../../../common/resources/client/uploads/requests/use-file-entry-model.ts","../../../common/resources/client/ui/forms/input-field/file-entry-field.tsx","../../../resources/client/admin/videos/crupdate/crupdate-caption-dialog.tsx","../../../common/resources/client/icons/material/Subtitles.tsx","../../../resources/client/admin/videos/captions/captions-panel.tsx","../../../resources/client/admin/videos/crupdate/crupdate-video-form.tsx","../../../resources/client/admin/videos/crupdate/create-video-page.tsx","../../../resources/client/admin/videos/requests/use-update-video.ts","../../../resources/client/admin/videos/requests/use-video.ts","../../../resources/client/admin/videos/crupdate/edit-video-page.tsx","../../../resources/client/admin/titles/movie-night.svg","../../../resources/client/admin/titles/titles-datatable-columns.tsx","../../../resources/client/admin/titles/titles-datatable-filters.tsx","../../../resources/client/admin/titles/requests/use-import-single-from-tmdb.ts","../../../resources/client/admin/titles/import/import-single-from-tmdb-dialog.tsx","../../../resources/client/admin/titles/requests/use-import-multiple-from-tmdb.ts","../../../resources/client/admin/titles/import/import-multiple-from-tmdb-dialog.tsx","../../../resources/client/admin/titles/titles-datatable-page.tsx","../../../resources/client/admin/titles/title-editor/edit-title-page.tsx","../../../resources/client/episodes/requests/use-delete-episode.ts","../../../resources/client/admin/titles/title-editor/title-editor-layout.tsx","../../../resources/client/admin/titles/title-editor/seasons-editor/season-editor-layout.tsx","../../../resources/client/admin/titles/title-editor/title-editor-page-status.tsx","../../../resources/client/admin/titles/title-editor/seasons-editor/season-editor-episode-list.tsx","../../../resources/client/admin/titles/requests/use-delete-season.ts","../../../resources/client/admin/titles/requests/use-create-season.ts","../../../resources/client/admin/titles/title-editor/seasons-editor/title-seasons-editor.tsx","../../../resources/client/admin/titles/requests/use-create-title.ts","../../../resources/client/admin/titles/requests/use-update-title.ts","../../../common/resources/client/ui/forms/combobox/form-combobox.tsx","../../../resources/client/admin/titles/title-editor/title-primary-facts-form.tsx","../../../resources/client/admin/titles/title-editor/title-reviews-editor.tsx","../../../common/resources/client/icons/material/ZoomOutMap.tsx","../../../resources/client/admin/titles/requests/use-delete-image.ts","../../../resources/client/admin/titles/requests/use-upload-image.ts","../../../resources/client/admin/titles/title-editor/title-images-editor.tsx","../../../resources/client/admin/titles/title-editor/videos-editor/title-videos-sort-button.tsx","../../../resources/client/admin/videos/requests/use-delete-videos.ts","../../../resources/client/seasons/requests/use-season-episode-numbers.ts","../../../resources/client/admin/titles/title-editor/videos-editor/videos-editor-season-select.tsx","../../../resources/client/admin/titles/title-editor/videos-editor/title-videos-editor.tsx","../../../resources/client/episodes/requests/use-update-episode.ts","../../../resources/client/admin/titles/title-editor/episode-editor/episode-editor-layout.tsx","../../../resources/client/episodes/requests/use-create-episode.ts","../../../resources/client/admin/titles/title-editor/episode-editor/episode-primary-facts-form.tsx","../../../resources/client/admin/titles/requests/use-title-credits.ts","../../../resources/client/admin/titles/requests/use-sort-title-credits.ts","../../../resources/client/admin/titles/requests/use-update-title-credit.ts","../../../resources/client/admin/titles/requests/use-create-title-credit.ts","../../../resources/client/admin/titles/title-editor/credits-editor/add-credit-dialog.tsx","../../../resources/client/admin/titles/title-editor/credits-editor/edit-credit-dialog.tsx","../../../resources/client/admin/titles/requests/use-delete-title-credit.ts","../../../resources/client/admin/titles/title-editor/credits-editor/get-credits-editor-action-column.tsx","../../../common/resources/client/icons/material/RecentActors.tsx","../../../resources/client/admin/titles/title-editor/credits-editor/credits-table-query-indicator.tsx","../../../resources/client/admin/titles/title-editor/credits-editor/cast-editor-table.tsx","../../../resources/client/admin/titles/title-editor/credits-editor/title-credits-table-header.tsx","../../../resources/client/admin/titles/title-editor/episode-editor/episode-cast-editor.tsx","../../../resources/client/admin/titles/title-editor/credits-editor/title-cast-editor.tsx","../../../resources/client/admin/titles/title-editor/credits-editor/crew-editor-table.tsx","../../../resources/client/admin/titles/title-editor/credits-editor/title-crew-editor.tsx","../../../resources/client/admin/titles/title-editor/seasons-editor/season-cast-editor.tsx","../../../resources/client/admin/titles/title-editor/seasons-editor/season-crew-editor.tsx","../../../resources/client/admin/titles/title-editor/episode-editor/episode-crew-editor.tsx","../../../resources/client/admin/titles/requests/use-detach-title-tag.ts","../../../resources/client/admin/titles/requests/use-attach-title-tag.ts","../../../resources/client/admin/titles/title-editor/title-tags-editor/add-title-tag-dialog.tsx","../../../resources/client/admin/titles/title-editor/title-tags-editor/title-tags-editor.tsx","../../../resources/client/admin/titles/title-editor/title-comments-editor.tsx","../../../resources/client/admin/people/awards.svg","../../../resources/client/admin/people/people-datatable-columns.tsx","../../../resources/client/admin/people/people-datatable-filters.tsx","../../../resources/client/admin/people/people-datatable-page.tsx","../../../resources/client/admin/people/requests/use-create-person.ts","../../../resources/client/admin/people/crupdate/person-primary-facts-form.tsx","../../../resources/client/admin/people/crupdate/create-person-page.tsx","../../../resources/client/admin/people/requests/use-update-person.ts","../../../resources/client/admin/people/crupdate/update-person-page.tsx","../../../resources/client/admin/people/requests/use-delete-person-credit.ts","../../../resources/client/admin/people/crupdate/person-credits-editor.tsx","../../../common/resources/client/article-editor/article-editor-title.tsx","../../../common/resources/client/icons/material/Undo.tsx","../../../common/resources/client/icons/material/Redo.tsx","../../../common/resources/client/text-editor/menubar/history-buttons.tsx","../../../common/resources/client/icons/material/Code.tsx","../../../common/resources/client/text-editor/menubar/mode-button.tsx","../../../common/resources/client/text-editor/menubar/divider.tsx","../../../common/resources/client/icons/material/FormatBold.tsx","../../../common/resources/client/icons/material/FormatItalic.tsx","../../../common/resources/client/icons/material/FormatUnderlined.tsx","../../../common/resources/client/text-editor/menubar/font-style-buttons.tsx","../../../common/resources/client/icons/material/FormatListBulleted.tsx","../../../common/resources/client/icons/material/FormatListNumbered.tsx","../../../common/resources/client/text-editor/menubar/list-buttons.tsx","../../../common/resources/client/text-editor/insert-link-into-text-editor.ts","../../../common/resources/client/text-editor/menubar/link-button.tsx","../../../common/resources/client/text-editor/menubar/image-button.tsx","../../../common/resources/client/icons/material/FormatClear.tsx","../../../common/resources/client/text-editor/menubar/clear-format-button.tsx","../../../common/resources/client/icons/material/HorizontalRule.tsx","../../../common/resources/client/icons/material/PriorityHigh.tsx","../../../common/resources/client/icons/material/Note.tsx","../../../common/resources/client/icons/material/SmartDisplay.tsx","../../../common/resources/client/text-editor/menubar/insert-menu-trigger.tsx","../../../common/resources/client/ui/keyboard/keyboard.tsx","../../../common/resources/client/text-editor/menubar/format-menu-trigger.tsx","../../../common/resources/client/icons/material/FormatColorText.tsx","../../../common/resources/client/icons/material/FormatColorFill.tsx","../../../common/resources/client/text-editor/menubar/color-buttons.tsx","../../../common/resources/client/icons/material/FormatAlignLeft.tsx","../../../common/resources/client/icons/material/FormatAlignCenter.tsx","../../../common/resources/client/icons/material/FormatAlignRight.tsx","../../../common/resources/client/icons/material/FormatAlignJustify.tsx","../../../common/resources/client/text-editor/menubar/align-buttons.tsx","../../../common/resources/client/icons/material/FormatIndentDecrease.tsx","../../../common/resources/client/icons/material/FormatIndentIncrease.tsx","../../../common/resources/client/text-editor/menubar/indent-buttons.tsx","../../../common/resources/client/text-editor/menubar/code-block-menu-trigger.tsx","../../../common/resources/client/article-editor/article-body-editor-menubar.tsx","../../../common/resources/client/article-editor/article-editor-sticky-header.tsx","../../../resources/client/admin/news/requests/use-update-news-article.ts","../../../resources/client/admin/news/edit-news-article-page.tsx","../../../resources/client/admin/news/requests/use-create-news-article.ts","../../../resources/client/admin/news/create-news-article-page.tsx","../../../resources/client/admin/title-tags/title-tags-editor/requests/use-create-title-tag.ts","../../../resources/client/admin/title-tags/title-tags-editor/create-title-tag-dialog.tsx","../../../resources/client/admin/title-tags/title-tags-editor/requests/use-update-title-tag.ts","../../../resources/client/admin/title-tags/title-tags-editor/update-title-tag-dialog.tsx","../../../resources/client/admin/title-tags/title-tags-editor/title-tags-datatable-filters.ts","../../../resources/client/admin/title-tags/title-tags-editor/title-tags-datatable-page.tsx","../../../resources/client/admin/lists/lists-datatable-columns.tsx","../../../resources/client/admin/lists/lists-datatable-page.tsx","../../../common/resources/client/admin/analytics/report-date-selector.tsx","../../../common/resources/client/ui/buttons/button-group.tsx","../../../common/resources/client/icons/material/TrendingUp.tsx","../../../common/resources/client/icons/material/TrendingDown.tsx","../../../common/resources/client/charts/chart-layout.tsx","../../../common/resources/client/charts/chart-loading-indicator.tsx","../../../common/resources/client/charts/base-chart.tsx","../../../common/resources/client/charts/data/format-report-data.ts","../../../common/resources/client/charts/chart-colors.tsx","../../../common/resources/client/charts/line-chart.tsx","../../../common/resources/client/charts/polar-area-chart.tsx","../../../common/resources/client/charts/bar-chart.tsx","../../../common/resources/client/admin/analytics/geo-chart/use-google-geo-chart.ts","../../../common/resources/client/i18n/formatted-country-name.tsx","../../../common/resources/client/admin/analytics/geo-chart/geo-chart.tsx","../../../common/resources/client/admin/analytics/visitors-report-charts.tsx","../../../common/resources/client/icons/material/TrendingFlat.tsx","../../../common/resources/client/admin/analytics/admin-header-report.tsx","../../../common/resources/client/admin/analytics/use-admin-report.ts","../../../resources/client/admin/reports/mtdb-admin-report-page.tsx","../../../resources/client/admin/reports/insights/insights-report-row.tsx","../../../resources/client/admin/reports/requests/use-insights-report.ts","../../../resources/client/admin/reports/insights/insights-charts-context.ts","../../../resources/client/admin/reports/insights/insights-async-chart.tsx","../../../resources/client/admin/reports/insights/insights-plays-chart.tsx","../../../resources/client/admin/reports/insights/insights-devices-chart.tsx","../../../common/resources/client/icons/material/Info.tsx","../../../resources/client/admin/reports/top-models-chart-layout.tsx","../../../resources/client/admin/reports/insights/insights-series-chart.tsx","../../../resources/client/admin/reports/insights/insights-movies-chart.tsx","../../../resources/client/admin/reports/insights/insights-videos-chart.tsx","../../../resources/client/admin/reports/insights/insights-users-chart.tsx","../../../resources/client/admin/reports/insights/insights-locations-chart.tsx","../../../resources/client/admin/reports/insights/insights-platforms-chart.tsx","../../../resources/client/admin/reports/admin-insights-report.tsx","../../../resources/client/admin/reports/admin-visitors-report.tsx","../../../resources/client/admin/reports/model-insights-page-layout.tsx","../../../resources/client/admin/reports/insights/insights-seasons-chart.tsx","../../../resources/client/admin/reports/insights/insights-episodes-chart.tsx","../../../resources/client/admin/reports/pages/title-insights-page.tsx","../../../resources/client/admin/reports/pages/episode-insights-page.tsx","../../../resources/client/admin/reports/pages/season-insights-page.tsx","../../../resources/client/admin/reports/pages/video-insights-page.tsx","../../../resources/client/admin/app-admin-routes.tsx","../../../common/resources/client/admin/custom-pages/requests/use-update-custom-page.ts","../../../common/resources/client/admin/custom-pages/edit-custom-page.tsx","../../../common/resources/client/admin/custom-pages/requests/use-create-custom-page.ts","../../../common/resources/client/admin/custom-pages/create-custom-page.tsx","../../../common/resources/client/ui/font-selector/font.svg","../../../common/resources/client/ui/font-selector/font-selector-filters.tsx","../../../common/resources/client/i18n/use-filter.ts","../../../common/resources/client/ui/font-picker/browser-safe-fonts.ts","../../../common/resources/client/ui/font-selector/font-selector-state.ts","../../../common/resources/client/ui/font-selector/font-selector-pagination.tsx","../../../common/resources/client/ui/font-selector/font-selector.tsx","../../../common/resources/client/admin/appearance/sections/themes/theme-font-panel.tsx","../../../common/resources/client/admin/appearance/sections/themes/theme-radius-panel.tsx","../../../common/resources/client/admin/logging/logs-page.tsx","../../../common/resources/client/admin/logging/schedule/use-rerurun-scheduled-command.tsx","../../../common/resources/client/icons/material/EventRepeat.tsx","../../../common/resources/client/admin/logging/schedule/schedule-datatable-columns.tsx","../../../common/resources/client/admin/logging/schedule/timeline.svg","../../../common/resources/client/icons/material/Download.tsx","../../../common/resources/client/admin/logging/schedule/schedule-log-datatable.tsx","../../../common/resources/client/admin/logging/error/bug-fixing.svg","../../../common/resources/client/admin/logging/error/error-log-datatable-columns.tsx","../../../common/resources/client/admin/logging/error/error-log-entry-dialog.tsx","../../../common/resources/client/admin/logging/error/use-delete-error-log.ts","../../../common/resources/client/admin/logging/error/error-log-datatable.tsx","../../../common/resources/client/admin/logging/outgoing-email/opened.svg","../../../common/resources/client/admin/logging/outgoing-email/use-outgoing-email-log-item-with-mime.ts","../../../common/resources/client/admin/logging/outgoing-email/outgoing-email-log-entry-dialog.tsx","../../../common/resources/client/admin/logging/outgoing-email/outgoing-email-log-datatable-columns.tsx","../../../common/resources/client/admin/logging/outgoing-email/outgoing-email-log-datatable-filters.tsx","../../../common/resources/client/admin/logging/outgoing-email/outgoing-email-log-datatable.tsx","../../../common/resources/client/admin/admin-routes.tsx"],"sourcesContent":["import clsx from 'clsx';\nimport React from 'react';\nimport {CustomMenu} from '../menus/custom-menu';\nimport {Trans} from '../i18n/trans';\nimport {useSettings} from '../core/settings/use-settings';\n\ninterface Props {\n className?: string;\n isCompactMode?: boolean;\n}\nexport function AdminSidebar({className, isCompactMode}: Props) {\n const {version} = useSettings();\n return (\n \n to === '/admin'}\n menu=\"admin-sidebar\"\n orientation=\"vertical\"\n onlyShowIcons={isCompactMode}\n itemClassName={({isActive}) =>\n clsx(\n 'block w-full rounded-button py-12 px-16',\n isActive\n ? 'bg-primary/6 text-primary font-semibold'\n : 'hover:bg-hover',\n )\n }\n gap=\"gap-8\"\n />\n {!isCompactMode && (\n
\n \n
\n )}\n \n );\n}\n","import {createContext} from 'react';\n\nexport type DashboardSidenavStatus = 'open' | 'closed' | 'compact';\n\nexport interface DashboardContextValue {\n leftSidenavStatus: DashboardSidenavStatus;\n setLeftSidenavStatus: (status: DashboardSidenavStatus) => void;\n rightSidenavStatus: DashboardSidenavStatus;\n setRightSidenavStatus: (status: DashboardSidenavStatus) => void;\n isMobileMode: boolean | null;\n leftSidenavCanBeCompact?: boolean;\n name: string;\n}\n\nexport const DashboardLayoutContext = createContext(\n null!\n);\n","import {useEffect} from 'react';\n\nexport function useBlockBodyOverflow(disable: boolean = false) {\n useEffect(() => {\n if (disable) {\n document.documentElement.classList.remove('no-page-overflow');\n } else {\n document.documentElement.classList.add('no-page-overflow');\n }\n return () => {\n document.documentElement.classList.remove('no-page-overflow');\n };\n }, [disable]);\n}\n","import {ComponentPropsWithoutRef, useCallback, useMemo} from 'react';\nimport {\n DashboardLayoutContext,\n DashboardSidenavStatus,\n} from './dashboard-layout-context';\nimport {Underlay} from '../overlays/underlay';\nimport {AnimatePresence} from 'framer-motion';\nimport {useControlledState} from '@react-stately/utils';\nimport {useMediaQuery} from '../../utils/hooks/use-media-query';\nimport {\n getFromLocalStorage,\n setInLocalStorage,\n} from '../../utils/hooks/local-storage';\nimport {useBlockBodyOverflow} from '../../utils/hooks/use-block-body-overflow';\nimport clsx from 'clsx';\n\ninterface DashboardLayoutProps extends ComponentPropsWithoutRef<'div'> {\n name: string;\n leftSidenavCanBeCompact?: boolean;\n leftSidenavStatus?: DashboardSidenavStatus;\n onLeftSidenavChange?: (status: DashboardSidenavStatus) => void;\n rightSidenavStatus?: DashboardSidenavStatus;\n initialRightSidenavStatus?: DashboardSidenavStatus;\n onRightSidenavChange?: (status: DashboardSidenavStatus) => void;\n height?: string;\n gridClassName?: string;\n blockBodyOverflow?: boolean;\n}\nexport function DashboardLayout({\n children,\n leftSidenavStatus: leftSidenav,\n onLeftSidenavChange,\n rightSidenavStatus: rightSidenav,\n initialRightSidenavStatus,\n onRightSidenavChange,\n name,\n leftSidenavCanBeCompact,\n height = 'h-screen',\n className,\n gridClassName = 'dashboard-grid',\n blockBodyOverflow = true,\n ...domProps\n}: DashboardLayoutProps) {\n useBlockBodyOverflow(!blockBodyOverflow);\n const isMobile = useMediaQuery('(max-width: 1024px)');\n\n const isCompactModeInitially = useMemo(() => {\n return !name ? false : getFromLocalStorage(`${name}.sidenav.compact`);\n }, [name]);\n const defaultLeftSidenavStatus = isCompactModeInitially ? 'compact' : 'open';\n const [leftSidenavStatus, setLeftSidenavStatus] = useControlledState(\n leftSidenav,\n isMobile ? 'closed' : defaultLeftSidenavStatus,\n onLeftSidenavChange,\n );\n\n const rightSidenavStatusDefault = useMemo(() => {\n if (isMobile) {\n return 'closed';\n }\n if (initialRightSidenavStatus != null) {\n return initialRightSidenavStatus;\n }\n const userSelected = getFromLocalStorage(\n `${name}.sidenav.right.position`,\n 'open',\n );\n if (userSelected != null) {\n return userSelected;\n }\n return initialRightSidenavStatus || 'closed';\n }, [isMobile, name, initialRightSidenavStatus]);\n const [rightSidenavStatus, _setRightSidenavStatus] = useControlledState(\n rightSidenav,\n rightSidenavStatusDefault,\n onRightSidenavChange,\n );\n const setRightSidenavStatus = useCallback(\n (status: DashboardSidenavStatus) => {\n _setRightSidenavStatus(status);\n setInLocalStorage(`${name}.sidenav.right.position`, status);\n },\n [_setRightSidenavStatus, name],\n );\n\n const shouldShowUnderlay =\n isMobile && (leftSidenavStatus === 'open' || rightSidenavStatus === 'open');\n\n return (\n \n \n {children}\n \n {shouldShowUnderlay && (\n {\n setLeftSidenavStatus('closed');\n setRightSidenavStatus('closed');\n }}\n />\n )}\n \n \n \n );\n}\n","import {cloneElement, ReactElement} from 'react';\nimport clsx from 'clsx';\n\ninterface DashboardContentProps {\n children: ReactElement<{className: string}>;\n isScrollable?: boolean;\n}\nexport function DashboardContent({\n children,\n isScrollable = true,\n}: DashboardContentProps) {\n return cloneElement(children, {\n className: clsx(\n children.props.className,\n isScrollable && 'overflow-y-auto stable-scrollbar',\n 'dashboard-grid-content'\n ),\n });\n}\n","import clsx from 'clsx';\nimport {m} from 'framer-motion';\nimport {cloneElement, ReactElement, useContext} from 'react';\nimport {DashboardLayoutContext} from './dashboard-layout-context';\n\nexport interface DashboardSidenavChildrenProps {\n className?: string;\n isCompactMode?: boolean;\n}\n\nexport interface SidenavProps {\n className?: string;\n children: ReactElement;\n position?: 'left' | 'right';\n size?: 'sm' | 'md' | 'lg' | string;\n mode?: 'overlay';\n // absolute will place sidenav between navbar/footer, fixed will overlay it over nav/footer.\n overlayPosition?: 'absolute' | 'fixed';\n display?: 'flex' | 'block';\n overflow?: string;\n forceClosed?: boolean;\n}\nexport function DashboardSidenav({\n className,\n position,\n children,\n size = 'md',\n mode,\n overlayPosition = 'fixed',\n display = 'flex',\n overflow = 'overflow-hidden',\n forceClosed = false,\n}: SidenavProps) {\n const {\n isMobileMode,\n leftSidenavStatus,\n setLeftSidenavStatus,\n rightSidenavStatus,\n setRightSidenavStatus,\n } = useContext(DashboardLayoutContext);\n const status = position === 'left' ? leftSidenavStatus : rightSidenavStatus;\n const isOverlayMode = isMobileMode || mode === 'overlay';\n\n const variants = {\n open: {display, width: null as any},\n compact: {\n display,\n width: null as any,\n },\n closed: {\n width: 0,\n transitionEnd: {\n display: 'none',\n },\n },\n };\n\n const sizeClassName = getSize(status === 'compact' ? 'compact' : size);\n\n return (\n {\n // close sidenav when user clicks a link or button on mobile\n const target = e.target as HTMLElement;\n if (isMobileMode && (target.closest('button') || target.closest('a'))) {\n setLeftSidenavStatus('closed');\n setRightSidenavStatus('closed');\n }\n }}\n className={clsx(\n className,\n position === 'left'\n ? 'dashboard-grid-sidenav-left'\n : 'dashboard-grid-sidenav-right',\n 'will-change-[width]',\n overflow,\n sizeClassName,\n isOverlayMode && `${overlayPosition} bottom-0 top-0 z-20 shadow-2xl`,\n isOverlayMode && position === 'left' && 'left-0',\n isOverlayMode && position === 'right' && 'right-0',\n )}\n >\n {cloneElement(children, {\n className: clsx(\n children.props.className,\n 'w-full h-full',\n status === 'compact' && 'compact-scrollbar',\n ),\n isCompactMode: status === 'compact',\n })}\n \n );\n}\n\nfunction getSize(size: SidenavProps['size'] | 'compact'): string {\n switch (size) {\n case 'compact':\n return 'w-80';\n case 'sm':\n return 'w-224';\n case 'md':\n return 'w-240';\n case 'lg':\n return 'w-288';\n default:\n return size || '';\n }\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const MenuOpenIcon = createSvgIcon(\n \n, 'MenuOpenOutlined');\n","import {Navbar, NavbarProps} from '../navigation/navbar/navbar';\nimport {IconButton} from '../buttons/icon-button';\nimport React, {useContext} from 'react';\nimport clsx from 'clsx';\nimport {DashboardLayoutContext} from './dashboard-layout-context';\nimport {setInLocalStorage} from '../../utils/hooks/local-storage';\nimport {MenuOpenIcon} from '@common/icons/material/MenuOpen';\n\nexport interface DashboardNavbarProps\n extends Omit {\n hideToggleButton?: boolean;\n}\nexport function DashboardNavbar({\n children,\n className,\n hideToggleButton,\n ...props\n}: DashboardNavbarProps) {\n const {\n isMobileMode,\n leftSidenavStatus,\n setLeftSidenavStatus,\n name,\n leftSidenavCanBeCompact,\n } = useContext(DashboardLayoutContext);\n\n const shouldToggleCompactMode = leftSidenavCanBeCompact && !isMobileMode;\n const shouldShowToggle =\n !hideToggleButton && (isMobileMode || leftSidenavCanBeCompact);\n\n const handleToggle = () => {\n setLeftSidenavStatus(leftSidenavStatus === 'open' ? 'closed' : 'open');\n };\n\n const handleCompactModeToggle = () => {\n const newStatus = leftSidenavStatus === 'compact' ? 'open' : 'compact';\n setInLocalStorage(`${name}.sidenav.compact`, newStatus === 'compact');\n setLeftSidenavStatus(newStatus);\n };\n\n return (\n {\n if (shouldToggleCompactMode) {\n handleCompactModeToggle();\n } else {\n handleToggle();\n }\n }}\n >\n \n \n ) : undefined\n }\n {...props}\n >\n {children}\n \n );\n}\n","import {useQuery} from '@tanstack/react-query';\nimport {apiClient} from '@common/http/query-client';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\n\nexport interface AdminSetupAlert {\n title: string;\n description: string;\n}\n\ninterface Response extends BackendResponse {\n alerts: AdminSetupAlert[];\n}\n\nexport function useAdminSetupAlerts() {\n return useQuery({\n queryKey: ['admin-setup-alerts'],\n queryFn: () => fetchAlerts(),\n });\n}\n\nfunction fetchAlerts() {\n return apiClient\n .get(`admin/setup-alerts`)\n .then(response => response.data);\n}\n","import {Outlet} from 'react-router-dom';\nimport {AdminSidebar} from './admin-sidebar';\nimport {DashboardLayout} from '../ui/layout/dashboard-layout';\nimport {DashboardContent} from '../ui/layout/dashboard-content';\nimport {DashboardSidenav} from '../ui/layout/dashboard-sidenav';\nimport {DashboardNavbar} from '../ui/layout/dashboard-navbar';\nimport {\n AdminSetupAlert,\n useAdminSetupAlerts,\n} from '@common/admin/use-admin-setup-alerts';\nimport {SectionHelper} from '@common/ui/section-helper';\nimport {ErrorIcon} from '@common/icons/material/Error';\nimport {\n setInLocalStorage,\n useLocalStorage,\n} from '@common/utils/hooks/local-storage';\n\nexport function AdminLayout() {\n return (\n \n \n \n \n \n \n
\n \n \n
\n
\n
\n );\n}\n\nfunction SetupAlertsList() {\n const {data} = useAdminSetupAlerts();\n const [dismissValue] = useLocalStorage<{\n timestamp: number;\n } | null>('admin-setup-alert-dismissed', null);\n\n // show alert if 1 day passed since last dismiss\n const shouldShowAlert =\n !dismissValue || Date.now() - dismissValue.timestamp > 86400000;\n\n if (!data?.alerts.length || !shouldShowAlert) {\n return null;\n }\n\n return (\n
\n \n
\n );\n}\n\ninterface SetupAlertProps {\n alert: AdminSetupAlert;\n}\nfunction SetupAlert({alert}: SetupAlertProps) {\n const description = (\n
\n );\n return (\n }\n onClose={() => {\n setInLocalStorage('admin-setup-alert-dismissed', {\n timestamp: Date.now(),\n });\n }}\n key={alert.title}\n title={alert.title}\n description={description}\n color=\"neutral\"\n />\n );\n}\n","import {\n BackendFilter,\n DatePickerFilterControl,\n FilterControlType,\n FilterOperator,\n} from './backend-filter';\nimport {\n DateRangePreset,\n DateRangePresets,\n} from '../../ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';\nimport {message} from '../../i18n/message';\nimport {dateRangeToAbsoluteRange} from '../../ui/forms/input-field/date/date-range-picker/form-date-range-picker';\nimport {PartialWithRequired} from '@common/utils/ts/partial-with-required';\n\nexport function timestampFilter(\n options: PartialWithRequired<\n BackendFilter,\n 'key' | 'label'\n >\n): BackendFilter {\n return {\n ...options,\n defaultOperator: FilterOperator.between,\n control: {\n type: FilterControlType.DateRangePicker,\n defaultValue:\n options.control?.defaultValue ||\n dateRangeToAbsoluteRange(\n (DateRangePresets[3] as Required).getRangeValue()\n ),\n },\n };\n}\n\nexport function createdAtFilter(\n options: Partial>\n): BackendFilter {\n return timestampFilter({\n key: 'created_at',\n label: message('Date created'),\n ...options,\n });\n}\n\nexport function updatedAtFilter(\n options: Partial>\n): BackendFilter {\n return timestampFilter({\n key: 'updated_at',\n label: message('Last updated'),\n ...options,\n });\n}\n","import {\n BackendFilter,\n FilterControlType,\n FilterOperator,\n} from '../../datatable/filters/backend-filter';\nimport {\n createdAtFilter,\n updatedAtFilter,\n} from '../../datatable/filters/timestamp-filters';\nimport {message} from '../../i18n/message';\n\nexport const UserDatatableFilters: BackendFilter[] = [\n {\n key: 'email_verified_at',\n label: message('Email'),\n description: message('Email verification status'),\n defaultOperator: FilterOperator.ne,\n control: {\n type: FilterControlType.Select,\n defaultValue: '01',\n options: [\n {\n key: '01',\n label: message('is confirmed'),\n value: {value: null, operator: FilterOperator.ne},\n },\n {\n key: '02',\n label: message('is not confirmed'),\n value: {value: null, operator: FilterOperator.eq},\n },\n ],\n },\n },\n createdAtFilter({\n description: message('Date user registered or was created'),\n }),\n updatedAtFilter({\n description: message('Date user was last updated'),\n }),\n {\n key: 'subscriptions',\n label: message('Subscription'),\n description: message('Whether user is subscribed or not'),\n defaultOperator: FilterOperator.eq,\n control: {\n type: FilterControlType.Select,\n defaultValue: '01',\n options: [\n {\n key: '01',\n label: message('is subscribed'),\n value: {value: '*', operator: FilterOperator.has},\n },\n {\n key: '02',\n label: message('is not subscribed'),\n value: {value: '*', operator: FilterOperator.doesntHave},\n },\n ],\n },\n },\n];\n","import React, {useContext} from 'react';\nimport {GetDatatableDataParams} from '../requests/paginated-resources';\nimport {UseQueryResult} from '@tanstack/react-query';\nimport {PaginatedBackendResponse} from '../../http/backend-response/pagination-response';\n\nexport interface DataTableContextValue {\n selectedRows: (string | number)[];\n setSelectedRows: (keys: (string | number)[]) => void;\n endpoint: string;\n params: GetDatatableDataParams;\n setParams: (value: GetDatatableDataParams) => void;\n query: UseQueryResult & A, unknown>;\n}\n\nexport const DataTableContext = React.createContext(\n null!,\n);\n\nexport function useDataTable() {\n return useContext(DataTableContext) as DataTableContextValue;\n}\n","import {UseQueryResult} from '@tanstack/react-query';\nimport {\n hasNextPage,\n LengthAwarePaginationResponse,\n PaginatedBackendResponse,\n} from '../http/backend-response/pagination-response';\nimport {useNumberFormatter} from '../i18n/use-number-formatter';\nimport {Select} from '../ui/forms/select/select';\nimport {Trans} from '../i18n/trans';\nimport {Item} from '../ui/forms/listbox/item';\nimport {IconButton} from '../ui/buttons/icon-button';\nimport {KeyboardArrowLeftIcon} from '../icons/material/KeyboardArrowLeft';\nimport {KeyboardArrowRightIcon} from '../icons/material/KeyboardArrowRight';\nimport React from 'react';\nimport {useIsMobileMediaQuery} from '../utils/hooks/is-mobile-media-query';\nimport clsx from 'clsx';\n\nconst defaultPerPage = 15;\nconst perPageOptions = [{key: 10}, {key: 15}, {key: 20}, {key: 50}, {key: 100}];\n\ntype DataTablePaginationFooterProps = {\n query: UseQueryResult, unknown>;\n onPerPageChange?: (perPage: number) => void;\n onPageChange?: (page: number) => void;\n className?: string;\n};\nexport function DataTablePaginationFooter({\n query,\n onPerPageChange,\n onPageChange,\n className,\n}: DataTablePaginationFooterProps) {\n const isMobile = useIsMobileMediaQuery();\n const numberFormatter = useNumberFormatter();\n const pagination = query.data\n ?.pagination as LengthAwarePaginationResponse;\n\n if (!pagination) return null;\n\n const perPageSelect = onPerPageChange ? (\n }\n selectedValue={pagination.per_page || defaultPerPage}\n onSelectionChange={value => onPerPageChange(value as number)}\n >\n {perPageOptions.map(option => (\n \n {option.key}\n \n ))}\n \n ) : null;\n\n return (\n \n {!isMobile && perPageSelect}\n {pagination.from && pagination.to && 'total' in pagination ? (\n
\n \n
\n ) : null}\n
\n {\n onPageChange?.(pagination?.current_page - 1);\n }}\n >\n \n \n {\n onPageChange?.(pagination?.current_page + 1);\n }}\n >\n \n \n
\n \n );\n}\n","import React, {ComponentPropsWithoutRef, ReactNode} from 'react';\nimport {BackendFilter} from './filters/backend-filter';\nimport {useTrans} from '../i18n/use-trans';\nimport {TextField} from '../ui/forms/input-field/text-field/text-field';\nimport {SearchIcon} from '../icons/material/Search';\nimport {AddFilterButton} from './filters/add-filter-button';\nimport {MessageDescriptor} from '@common/i18n/message-descriptor';\nimport {message} from '@common/i18n/message';\n\ninterface Props {\n actions?: ReactNode;\n filters?: BackendFilter[];\n filtersLoading?: boolean;\n searchPlaceholder?: MessageDescriptor;\n searchValue?: string;\n onSearchChange: (value: string) => void;\n}\nexport function DataTableHeader({\n actions,\n filters,\n filtersLoading,\n searchPlaceholder = message('Type to search...'),\n searchValue = '',\n onSearchChange,\n}: Props) {\n const {trans} = useTrans();\n return (\n \n }\n value={searchValue}\n onChange={e => {\n onSearchChange(e.target.value);\n }}\n />\n {filters && (\n \n )}\n {actions}\n \n );\n}\n\ninterface AnimatedHeaderProps extends ComponentPropsWithoutRef<'div'> {\n children: ReactNode;\n}\nexport function HeaderLayout({children, ...domProps}: AnimatedHeaderProps) {\n return (\n \n {children}\n \n );\n}\n","import {Trans} from '@common/i18n/trans';\nimport React, {ReactNode} from 'react';\nimport {HeaderLayout} from '@common/datatable/data-table-header';\n\ninterface Props {\n actions?: ReactNode;\n selectedItemsCount: number;\n}\nexport function SelectedStateDatatableHeader({\n actions,\n selectedItemsCount,\n}: Props) {\n return (\n \n
\n \n
\n {actions}\n
\n );\n}\n","import React, {\n cloneElement,\n ComponentProps,\n ReactElement,\n ReactNode,\n useState,\n} from 'react';\nimport {TableDataItem} from '../ui/tables/types/table-data-item';\nimport {BackendFilter} from './filters/backend-filter';\nimport {MessageDescriptor} from '../i18n/message-descriptor';\nimport {ColumnConfig} from './column-config';\nimport {useTrans} from '../i18n/use-trans';\nimport {useBackendFilterUrlParams} from './filters/backend-filter-url-params';\nimport {\n GetDatatableDataParams,\n useDatatableData,\n} from './requests/paginated-resources';\nimport {DataTableContext} from './page/data-table-context';\nimport {AnimatePresence, m} from 'framer-motion';\nimport {ProgressBar} from '../ui/progress/progress-bar';\nimport {Table, TableProps} from '../ui/tables/table';\nimport {DataTablePaginationFooter} from './data-table-pagination-footer';\nimport {DataTableHeader} from './data-table-header';\nimport {FilterList} from './filters/filter-list/filter-list';\nimport {SelectedStateDatatableHeader} from '@common/datatable/selected-state-datatable-header';\nimport clsx from 'clsx';\nimport {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';\nimport {BackendFiltersUrlKey} from '@common/datatable/filters/backend-filters-url-key';\nimport {opacityAnimation} from '@common/ui/animation/opacity-animation';\nimport {FilterListSkeleton} from '@common/datatable/filters/filter-list/filter-list-skeleton';\n\nexport interface DataTableProps {\n filters?: BackendFilter[];\n filtersLoading?: boolean;\n columns: ColumnConfig[];\n searchPlaceholder?: MessageDescriptor;\n queryParams?: Record;\n endpoint: string;\n resourceName?: ReactNode;\n emptyStateMessage: ReactElement<{isFiltering: boolean}>;\n actions?: ReactNode;\n enableSelection?: boolean;\n selectionStyle?: TableProps['selectionStyle'];\n selectedActions?: ReactNode;\n onRowAction?: TableProps['onAction'];\n tableDomProps?: ComponentProps<'table'>;\n children?: ReactNode;\n collapseTableOnMobile?: boolean;\n cellHeight?: string;\n}\nexport function DataTable({\n filters,\n filtersLoading,\n columns,\n searchPlaceholder,\n queryParams,\n endpoint,\n actions,\n selectedActions,\n emptyStateMessage,\n tableDomProps,\n onRowAction,\n enableSelection = true,\n selectionStyle = 'checkbox',\n children,\n cellHeight,\n collapseTableOnMobile = true,\n}: DataTableProps) {\n const isMobile = useIsMobileMediaQuery();\n const {trans} = useTrans();\n const {encodedFilters} = useBackendFilterUrlParams(filters);\n const [params, setParams] = useState({perPage: 15});\n const [selectedRows, setSelectedRows] = useState<(string | number)[]>([]);\n const query = useDatatableData(\n endpoint,\n {\n ...params,\n ...queryParams,\n [BackendFiltersUrlKey]: encodedFilters,\n },\n undefined,\n () => setSelectedRows([]),\n );\n\n const isFiltering = !!(params.query || params.filters || encodedFilters);\n const pagination = query.data?.pagination;\n\n return (\n \n {children}\n \n {selectedRows.length ? (\n \n ) : (\n setParams({...params, query})}\n actions={actions}\n filters={filters}\n filtersLoading={filtersLoading}\n key=\"default\"\n />\n )}\n \n\n {filters && (\n
\n \n {filtersLoading && encodedFilters ? (\n \n ) : (\n \n \n \n )}\n \n
\n )}\n\n \n {query.isFetching && (\n \n )}\n\n
\n {\n setParams({...params, ...descriptor});\n }}\n selectedRows={selectedRows}\n enableSelection={enableSelection}\n selectionStyle={selectionStyle}\n onSelectionChange={setSelectedRows}\n onAction={onRowAction}\n collapseOnMobile={collapseTableOnMobile}\n cellHeight={cellHeight}\n />\n
\n\n {(query.isFetched || query.isPlaceholderData) &&\n !pagination?.data.length ? (\n
\n {cloneElement(emptyStateMessage, {\n isFiltering,\n })}\n
\n ) : undefined}\n\n setParams({...params, page})}\n onPerPageChange={perPage => setParams({...params, perPage})}\n />\n \n \n );\n}\n","import React, {ReactElement, ReactNode, useId} from 'react';\nimport {TableDataItem} from '../../ui/tables/types/table-data-item';\nimport {DataTable, DataTableProps} from '../data-table';\nimport {TableProps} from '../../ui/tables/table';\nimport {StaticPageTitle} from '../../seo/static-page-title';\nimport {MessageDescriptor} from '../../i18n/message-descriptor';\nimport clsx from 'clsx';\n\ninterface Props extends DataTableProps {\n title?: ReactElement;\n headerContent?: ReactNode;\n headerItemsAlign?: string;\n enableSelection?: boolean;\n onRowAction?: TableProps['onAction'];\n padding?: string;\n className?: string;\n}\nexport function DataTablePage({\n title,\n headerContent,\n headerItemsAlign = 'items-end',\n className,\n padding,\n ...dataTableProps\n}: Props) {\n const titleId = useId();\n\n return (\n
\n {title && (\n \n {title}\n

\n {title}\n

\n {headerContent}\n
\n )}\n\n \n \n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient, queryClient} from '../../http/query-client';\nimport {BackendResponse} from '../../http/backend-response/backend-response';\nimport {toast} from '../../ui/toast/toast';\nimport {DatatableDataQueryKey} from './paginated-resources';\nimport {useDataTable} from '../page/data-table-context';\nimport {message} from '../../i18n/message';\nimport {showHttpErrorToast} from '../../utils/http/show-http-error-toast';\nimport {Key} from 'react';\n\ninterface Response extends BackendResponse {\n //\n}\n\nexport function useDeleteSelectedRows() {\n const {endpoint, selectedRows, setSelectedRows} = useDataTable();\n return useMutation({\n mutationFn: () => deleteSelectedRows(endpoint, selectedRows),\n onSuccess: async () => {\n await queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey(endpoint),\n });\n toast(\n message('Deleted [one 1 record|other :count records]', {\n values: {count: selectedRows.length},\n }),\n );\n setSelectedRows([]);\n },\n onError: err =>\n showHttpErrorToast(err, message('Could not delete records')),\n });\n}\n\nfunction deleteSelectedRows(endpoint: string, ids: Key[]): Promise {\n return apiClient.delete(`${endpoint}/${ids.join(',')}`).then(r => r.data);\n}\n","import {Button} from '../../ui/buttons/button';\nimport {Trans} from '../../i18n/trans';\nimport {ConfirmationDialog} from '../../ui/overlays/dialog/confirmation-dialog';\nimport {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';\nimport React from 'react';\nimport {useDeleteSelectedRows} from '../requests/delete-selected-rows';\nimport {useDataTable} from './data-table-context';\nimport {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';\nimport {errorStatusIs} from '@common/utils/http/error-status-is';\n\nexport function DeleteSelectedItemsAction() {\n return (\n \n \n \n \n );\n}\n\nfunction DeleteItemsDialog() {\n const deleteSelectedRows = useDeleteSelectedRows();\n const {selectedRows, setSelectedRows} = useDataTable();\n const {close} = useDialogContext();\n return (\n \n }\n body={\n \n }\n confirm={}\n isDanger\n onConfirm={() => {\n deleteSelectedRows.mutate(undefined, {\n onSuccess: () => close(),\n onError: err => {\n if (errorStatusIs(err, 422)) {\n setSelectedRows([]);\n close();\n }\n },\n });\n }}\n />\n );\n}\n","import React, {ReactNode} from 'react';\nimport {IllustratedMessage} from '../../ui/images/illustrated-message';\nimport {SvgImage} from '../../ui/images/svg-image/svg-image';\nimport {Trans} from '../../i18n/trans';\nimport {useIsMobileMediaQuery} from '../../utils/hooks/is-mobile-media-query';\n\nexport interface DataTableEmptyStateMessageProps {\n isFiltering?: boolean;\n title: ReactNode;\n filteringTitle?: ReactNode;\n image: string;\n size?: 'sm' | 'md';\n className?: string;\n}\nexport function DataTableEmptyStateMessage({\n isFiltering,\n title,\n filteringTitle,\n image,\n size,\n className,\n}: DataTableEmptyStateMessageProps) {\n const isMobile = useIsMobileMediaQuery();\n if (!size) {\n size = isMobile ? 'sm' : 'md';\n }\n\n // allow user to disable filtering message variation by not passing in \"filteringTitle\"\n return (\n }\n title={isFiltering && filteringTitle ? filteringTitle : title}\n description={\n isFiltering && filteringTitle ? (\n \n ) : undefined\n }\n />\n );\n}\n","export default \"__VITE_ASSET__d109d853__\"","import {AddIcon} from '../icons/material/Add';\nimport {Button} from '../ui/buttons/button';\nimport React, {ReactElement, ReactNode} from 'react';\nimport {useIsMobileMediaQuery} from '../utils/hooks/is-mobile-media-query';\nimport {IconButton} from '../ui/buttons/icon-button';\nimport {To} from 'react-router-dom';\nimport {ButtonBaseProps} from '../ui/buttons/button-base';\n\nexport interface DataTableAddItemButtonProps {\n children: ReactNode;\n to?: To;\n href?: string;\n download?: boolean | string;\n elementType?: ButtonBaseProps['elementType'];\n onClick?: ButtonBaseProps['onClick'];\n icon?: ReactElement;\n disabled?: boolean;\n}\nexport const DataTableAddItemButton = React.forwardRef<\n HTMLButtonElement,\n DataTableAddItemButtonProps\n>(\n (\n {children, to, elementType, onClick, href, download, icon, disabled},\n ref,\n ) => {\n const isMobile = useIsMobileMediaQuery();\n\n if (isMobile) {\n return (\n \n {icon || }\n \n );\n }\n\n return (\n }\n variant=\"flat\"\n color=\"primary\"\n size=\"sm\"\n to={to}\n href={href}\n download={download}\n elementType={elementType}\n onClick={onClick}\n disabled={disabled}\n >\n {children}\n \n );\n },\n);\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const FileDownloadIcon = createSvgIcon(\n \n, 'FileDownloadOutlined');\n","import {apiClient} from '../../http/query-client';\nimport {BackendResponse} from '../../http/backend-response/backend-response';\nimport {useMutation} from '@tanstack/react-query';\nimport {showHttpErrorToast} from '../../utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {\n downloadPath?: string;\n result?: 'jobQueued';\n}\n\nexport type ExportCsvPayload = Record;\n\nexport function useExportCsv(endpoint: string) {\n return useMutation({\n mutationFn: (payload?: ExportCsvPayload) => exportCsv(endpoint, payload),\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction exportCsv(\n endpoint: string,\n payload: ExportCsvPayload | undefined,\n): Promise {\n return apiClient.post(endpoint, payload).then(r => r.data);\n}\n","export function downloadFileFromUrl(url: string, name?: string) {\n const link = document.createElement('a');\n link.href = url;\n if (name) link.download = name;\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n}\n","import {DialogBody} from '../../ui/overlays/dialog/dialog-body';\nimport {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';\nimport {DialogHeader} from '../../ui/overlays/dialog/dialog-header';\nimport {useDialogContext} from '../../ui/overlays/dialog/dialog-context';\nimport {Dialog} from '../../ui/overlays/dialog/dialog';\nimport {Button} from '../../ui/buttons/button';\nimport {Trans} from '../../i18n/trans';\n\nexport function CsvExportInfoDialog() {\n const {close} = useDialogContext();\n return (\n \n \n \n \n \n \n \n \n \n \n \n );\n}\n","import {IconButton} from '../../ui/buttons/icon-button';\nimport {FileDownloadIcon} from '../../icons/material/FileDownload';\nimport React, {Fragment, useState} from 'react';\nimport {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';\nimport {ExportCsvPayload, useExportCsv} from '../requests/use-export-csv';\nimport {downloadFileFromUrl} from '../../uploads/utils/download-file-from-url';\nimport {CsvExportInfoDialog} from './csv-export-info-dialog';\n\ninterface DataTableExportCsvButtonProps {\n endpoint: string;\n payload?: ExportCsvPayload;\n}\nexport function DataTableExportCsvButton({\n endpoint,\n payload,\n}: DataTableExportCsvButtonProps) {\n const [dialogIsOpen, setDialogIsOpen] = useState(false);\n const exportCsv = useExportCsv(endpoint);\n\n return (\n \n {\n exportCsv.mutate(payload, {\n onSuccess: response => {\n if (response.downloadPath) {\n downloadFileFromUrl(response.downloadPath);\n } else {\n setDialogIsOpen(true);\n }\n },\n });\n }}\n >\n \n \n \n \n \n \n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const PersonOffIcon = createSvgIcon(\n \n, 'PersonOffOutlined');\n","import {useMutation} from '@tanstack/react-query';\nimport {UseFormReturn} from 'react-hook-form';\nimport {User} from '@common/auth/user';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {toast} from '@common/ui/toast/toast';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {onFormQueryError} from '@common/errors/on-form-query-error';\nimport {message} from '@common/i18n/message';\n\ninterface Response extends BackendResponse {\n user: User;\n}\n\nexport interface BanUserPayload {\n ban_until?: string;\n permanent?: boolean;\n comment?: string;\n}\n\nexport function useBanUser(\n form: UseFormReturn,\n userId: number,\n) {\n return useMutation({\n mutationFn: (payload: BanUserPayload) => banUser(userId, payload),\n onSuccess: async () => {\n toast(message('User suspended'));\n await queryClient.invalidateQueries({queryKey: ['users']});\n },\n onError: r => onFormQueryError(r, form),\n });\n}\n\nfunction banUser(userId: number, payload: BanUserPayload): Promise {\n return apiClient.post(`users/${userId}/ban`, payload).then(r => r.data);\n}\n","import {useControlledState} from '@react-stately/utils';\nimport {HTMLAttributes, useCallback, useState} from 'react';\nimport {\n CalendarDate,\n DateValue,\n isSameDay,\n toCalendarDate,\n toZoned,\n ZonedDateTime,\n} from '@internationalized/date';\nimport {useBaseDatePickerState} from '../use-base-date-picker-state';\nimport {useCurrentDateTime} from '@common/i18n/use-current-date-time';\n\nexport type Granularity = 'day' | 'minute';\n\nexport type DatePickerState = BaseDatePickerState;\n\nexport interface BaseDatePickerState {\n timezone: string;\n granularity: Granularity;\n selectedValue: T;\n setSelectedValue: (value: T) => void;\n calendarIsOpen: boolean;\n setCalendarIsOpen: (isOpen: boolean) => void;\n calendarDates: CalendarDate[];\n setCalendarDates: (dates: CalendarDate[]) => void;\n dayIsActive: (day: CalendarDate) => boolean;\n dayIsHighlighted: (day: CalendarDate) => boolean;\n dayIsRangeStart: (day: CalendarDate) => boolean;\n dayIsRangeEnd: (day: CalendarDate) => boolean;\n isPlaceholder: P;\n setIsPlaceholder: (value: P) => void;\n clear: () => void;\n min?: ZonedDateTime;\n max?: ZonedDateTime;\n closeDialogOnSelection: boolean;\n getCellProps: (\n date: CalendarDate,\n isSameMonth: boolean,\n ) => HTMLAttributes;\n}\n\nexport interface DatePickerValueProps {\n value?: V | null | '';\n defaultValue?: V | null;\n onChange?: (value: CV | null) => void;\n min?: DateValue;\n max?: DateValue;\n granularity?: Granularity;\n closeDialogOnSelection?: boolean;\n}\nexport function useDatePickerState(\n props: DatePickerValueProps,\n): BaseDatePickerState {\n const now = useCurrentDateTime();\n const [isPlaceholder, setIsPlaceholder] = useState(\n !props.value && !props.defaultValue,\n );\n\n // if user clears the date, we will want to still keep an\n // instance internally, but return null via \"onChange\" callback\n const setStateValue = props.onChange;\n const [internalValue, setInternalValue] = useControlledState(\n props.value || now,\n props.defaultValue || now,\n value => {\n setIsPlaceholder(false);\n setStateValue?.(value);\n },\n );\n\n const {\n min,\n max,\n granularity,\n timezone,\n calendarIsOpen,\n setCalendarIsOpen,\n closeDialogOnSelection,\n } = useBaseDatePickerState(internalValue, props);\n\n const clear = useCallback(() => {\n setIsPlaceholder(true);\n setInternalValue(now);\n setStateValue?.(null);\n setCalendarIsOpen(false);\n }, [now, setInternalValue, setStateValue, setCalendarIsOpen]);\n\n const [calendarDates, setCalendarDates] = useState(() => {\n return [toCalendarDate(internalValue)];\n });\n\n const setSelectedValue = useCallback(\n (newValue: DateValue) => {\n if (min && newValue.compare(min) < 0) {\n newValue = min;\n } else if (max && newValue.compare(max) > 0) {\n newValue = max;\n }\n\n // preserve time\n const value = internalValue\n ? internalValue.set(newValue)\n : toZoned(newValue, timezone);\n setInternalValue(value);\n setCalendarDates([toCalendarDate(value)]);\n setIsPlaceholder(false);\n },\n [setInternalValue, min, max, internalValue, timezone],\n );\n\n const dayIsActive = useCallback(\n (day: DateValue) => !isPlaceholder && isSameDay(internalValue, day),\n [internalValue, isPlaceholder],\n );\n\n const getCellProps = useCallback(\n (date: DateValue): HTMLAttributes => {\n return {\n onClick: () => {\n setSelectedValue?.(date);\n if (closeDialogOnSelection) {\n setCalendarIsOpen?.(false);\n }\n },\n };\n },\n [setSelectedValue, setCalendarIsOpen, closeDialogOnSelection],\n );\n\n return {\n selectedValue: internalValue,\n setSelectedValue: setInternalValue,\n calendarIsOpen,\n setCalendarIsOpen,\n dayIsActive,\n dayIsHighlighted: () => false,\n dayIsRangeStart: () => false,\n dayIsRangeEnd: () => false,\n getCellProps,\n calendarDates,\n setCalendarDates,\n isPlaceholder,\n clear,\n setIsPlaceholder,\n min,\n max,\n granularity,\n timezone,\n closeDialogOnSelection,\n };\n}\n","import React, {\n ComponentPropsWithoutRef,\n Fragment,\n MouseEvent,\n useRef,\n} from 'react';\nimport {parseAbsoluteToLocal, ZonedDateTime} from '@internationalized/date';\nimport {useController} from 'react-hook-form';\nimport {mergeProps} from '@react-aria/utils';\nimport {\n DatePickerValueProps,\n useDatePickerState,\n} from './use-date-picker-state';\nimport {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';\nimport {DateRangeIcon} from '@common/icons/material/DateRange';\nimport {Dialog} from '@common/ui/overlays/dialog/dialog';\nimport {DialogBody} from '@common/ui/overlays/dialog/dialog-body';\nimport {Calendar} from '../calendar/calendar';\nimport {\n DatePickerField,\n DatePickerFieldProps,\n} from '../date-range-picker/date-picker-field';\nimport {DateSegmentList} from '../segments/date-segment-list';\nimport {useDateFormatter} from '@common/i18n/use-date-formatter';\nimport {useTrans} from '@common/i18n/use-trans';\nimport clsx from 'clsx';\nimport {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';\nimport {Button} from '@common/ui/buttons/button';\nimport {Trans} from '@common/i18n/trans';\nimport {useCurrentDateTime} from '@common/i18n/use-current-date-time';\n\nexport interface DatePickerProps\n extends Omit,\n DatePickerValueProps {}\nexport function DatePicker({showCalendarFooter, ...props}: DatePickerProps) {\n const state = useDatePickerState(props);\n const inputRef = useRef(null);\n const now = useCurrentDateTime();\n\n const footer = showCalendarFooter && (\n {\n state.clear();\n }}\n >\n \n \n }\n >\n {\n state.setSelectedValue(now);\n state.setCalendarIsOpen(false);\n }}\n >\n \n \n \n );\n\n const dialog = (\n \n \n \n \n \n {footer}\n \n \n );\n\n const openOnClick: ComponentPropsWithoutRef<'div'> = {\n onClick: e => {\n e.stopPropagation();\n e.preventDefault();\n if (!isHourSegment(e)) {\n state.setCalendarIsOpen(true);\n } else {\n state.setCalendarIsOpen(false);\n }\n },\n };\n\n return (\n \n \n }\n {...props}\n >\n \n \n {dialog}\n \n );\n}\n\ninterface FormDatePickerProps extends DatePickerProps {\n name: string;\n}\nexport function FormDatePicker(props: FormDatePickerProps) {\n const {min, max} = props;\n const {trans} = useTrans();\n const {format} = useDateFormatter();\n const {\n field: {onChange, onBlur, value = null, ref},\n fieldState: {invalid, error},\n } = useController({\n name: props.name,\n rules: {\n validate: v => {\n if (!v) return;\n const date = parseAbsoluteToLocal(v);\n if (min && date.compare(min) < 0) {\n return trans({\n message: 'Enter a date after :date',\n values: {date: format(v)},\n });\n }\n if (max && date.compare(max) > 0) {\n return trans({\n message: 'Enter a date before :date',\n values: {date: format(v)},\n });\n }\n },\n },\n });\n\n const parsedValue: null | ZonedDateTime = value\n ? parseAbsoluteToLocal(value)\n : null;\n\n const formProps: Partial = {\n onChange: e => {\n onChange(e ? e.toAbsoluteString() : e);\n },\n onBlur,\n value: parsedValue,\n invalid,\n errorMessage: error?.message,\n inputRef: ref,\n };\n\n return ;\n}\n\nfunction isHourSegment(e: MouseEvent): boolean {\n return ['hour', 'minute', 'dayPeriod'].includes(\n (e.currentTarget as HTMLElement).ariaLabel || ''\n );\n}\n","import {Dialog} from '@common/ui/overlays/dialog/dialog';\nimport {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';\nimport {Trans} from '@common/i18n/trans';\nimport {DialogBody} from '@common/ui/overlays/dialog/dialog-body';\nimport {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';\nimport {Button} from '@common/ui/buttons/button';\nimport {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';\nimport {Form} from '@common/ui/forms/form';\nimport {useForm} from 'react-hook-form';\nimport {\n BanUserPayload,\n useBanUser,\n} from '@common/admin/users/requests/use-ban-user';\nimport {FormDatePicker} from '@common/ui/forms/input-field/date/date-picker/date-picker';\nimport {User} from '@common/auth/user';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {message} from '@common/i18n/message';\nimport {FormSwitch} from '@common/ui/forms/toggle/switch';\n\ninterface Props {\n user: User;\n}\nexport function BanUserDialog({user}: Props) {\n const {trans} = useTrans();\n const {close, formId} = useDialogContext();\n const form = useForm({\n defaultValues: {\n permanent: true,\n },\n });\n const isPermanent = form.watch('permanent');\n const banUser = useBanUser(form, user.id);\n return (\n \n \n \n \n \n \n banUser.mutate(values, {onSuccess: () => close()})\n }\n >\n }\n disabled={isPermanent}\n />\n \n \n \n }\n placeholder={trans(message('Optional'))}\n />\n \n \n \n \n \n \n \n \n \n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {toast} from '@common/ui/toast/toast';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {message} from '@common/i18n/message';\nimport {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {}\n\nexport function useUnbanUser(userId: number) {\n return useMutation({\n mutationFn: () => unbanUser(userId),\n onSuccess: () => {\n toast(message('User unsuspended'));\n queryClient.invalidateQueries({queryKey: ['users']});\n },\n onError: r => showHttpErrorToast(r),\n });\n}\n\nfunction unbanUser(userId: number): Promise {\n return apiClient.delete(`users/${userId}/unban`).then(r => r.data);\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {toast} from '@common/ui/toast/toast';\nimport {apiClient} from '@common/http/query-client';\nimport {message} from '@common/i18n/message';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {User} from '@common/auth/user';\nimport {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {\n user: User;\n}\n\ninterface Payload {\n userId: string | number;\n}\n\nexport function useImpersonateUser() {\n return useMutation({\n mutationFn: (payload: Payload) => impersonateUser(payload),\n onSuccess: async response => {\n toast(message(`Impersonating User \"${response.user.display_name}\"`));\n window.location.href = '/';\n },\n onError: r => showHttpErrorToast(r),\n });\n}\n\nfunction impersonateUser(payload: Payload) {\n return apiClient\n .post(`admin/users/impersonate/${payload.userId}`, payload)\n .then(r => r.data);\n}\n","import {ColumnConfig} from '@common/datatable/column-config';\nimport {User} from '@common/auth/user';\nimport {Trans} from '@common/i18n/trans';\nimport {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';\nimport {CheckIcon} from '@common/icons/material/Check';\nimport {CloseIcon} from '@common/icons/material/Close';\nimport {ChipList} from '@common/ui/forms/input-field/chip-field/chip-list';\nimport {Chip} from '@common/ui/forms/input-field/chip-field/chip';\nimport {Link} from 'react-router-dom';\nimport clsx from 'clsx';\nimport {FormattedDate} from '@common/i18n/formatted-date';\nimport {Tooltip} from '@common/ui/tooltip/tooltip';\nimport {IconButton} from '@common/ui/buttons/icon-button';\nimport {EditIcon} from '@common/icons/material/Edit';\nimport {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';\nimport {PersonOffIcon} from '@common/icons/material/PersonOff';\nimport {BanUserDialog} from '@common/admin/users/ban-user-dialog';\nimport React from 'react';\nimport {useUnbanUser} from '@common/admin/users/requests/use-unban-user';\nimport {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';\nimport {useImpersonateUser} from '@common/admin/users/requests/use-impersonate-user';\nimport {LoginIcon} from '@common/icons/material/Login';\n\nexport const userDatatableColumns: ColumnConfig[] = [\n {\n key: 'name',\n allowsSorting: true,\n sortingKey: 'email',\n width: 'flex-3 min-w-200',\n visibleInMode: 'all',\n header: () => ,\n body: user => (\n \n ),\n },\n {\n key: 'subscribed',\n header: () => ,\n width: 'w-96',\n body: user =>\n user.subscriptions?.length ? (\n \n ) : (\n \n ),\n },\n {\n key: 'roles',\n header: () => ,\n body: user => (\n \n {user?.roles?.map(role => (\n \n \n \n \n \n ))}\n \n ),\n },\n {\n key: 'firstName',\n allowsSorting: true,\n header: () => ,\n body: user => user.first_name,\n },\n {\n key: 'lastName',\n allowsSorting: true,\n header: () => ,\n body: user => user.last_name,\n },\n {\n key: 'createdAt',\n allowsSorting: true,\n width: 'w-96',\n header: () => ,\n body: user => (\n \n ),\n },\n {\n key: 'actions',\n header: () => ,\n width: 'w-128 flex-shrink-0',\n hideHeader: true,\n align: 'end',\n visibleInMode: 'all',\n body: user => (\n
\n \n }>\n \n \n \n \n \n {user.banned_at ? (\n \n ) : (\n \n }>\n \n \n \n \n \n \n )}\n \n
\n ),\n },\n];\n\ninterface UnbanButtonProps {\n user: User;\n}\nfunction UnbanButton({user}: UnbanButtonProps) {\n const unban = useUnbanUser(user.id);\n return (\n {\n if (confirmed) {\n unban.mutate();\n }\n }}\n >\n }>\n \n \n \n \n \n }\n body={\n \n }\n confirm={}\n />\n \n );\n}\n\ninterface ImpersonateButtonProps {\n user: User;\n}\nfunction ImpersonateButton({user}: ImpersonateButtonProps) {\n const impersonate = useImpersonateUser();\n return (\n \n }>\n \n \n \n \n \n }\n isLoading={impersonate.isPending}\n body={}\n confirm={}\n onConfirm={() => {\n impersonate.mutate({userId: user.id});\n }}\n />\n \n );\n}\n","import React, {Fragment} from 'react';\nimport {Link} from 'react-router-dom';\nimport {UserDatatableFilters} from './user-datatable-filters';\nimport {DataTablePage} from '../../datatable/page/data-table-page';\nimport {Trans} from '../../i18n/trans';\nimport {DeleteSelectedItemsAction} from '../../datatable/page/delete-selected-items-action';\nimport {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';\nimport teamSvg from '../roles/team.svg';\nimport {DataTableAddItemButton} from '../../datatable/data-table-add-item-button';\nimport {DataTableExportCsvButton} from '../../datatable/csv-export/data-table-export-csv-button';\nimport {useSettings} from '../../core/settings/use-settings';\nimport {userDatatableColumns} from '@common/admin/users/user-datatable-columns';\n\nexport function UserDatatable() {\n const {billing} = useSettings();\n\n const filteredColumns = !billing.enable\n ? userDatatableColumns.filter(c => c.key !== 'subscribed')\n : userDatatableColumns;\n\n return (\n \n }\n filters={UserDatatableFilters}\n columns={filteredColumns}\n actions={}\n queryParams={{with: 'subscriptions,bans'}}\n selectedActions={}\n emptyStateMessage={\n }\n filteringTitle={}\n />\n }\n />\n \n );\n}\n\nfunction Actions() {\n return (\n \n \n \n \n \n \n );\n}\n","export function chunkArray(array: T[], chunkSize: number): T[][] {\n return array.reduce((resultArray, item, index) => {\n const chunkIndex = Math.floor(index / chunkSize);\n\n if (!resultArray[chunkIndex]) {\n resultArray[chunkIndex] = [];\n }\n\n resultArray[chunkIndex].push(item);\n\n return resultArray;\n }, []);\n}\n","import {\n IAppearanceConfig,\n MenuSectionConfig,\n} from '@common/admin/appearance/types/appearance-editor-config';\nimport {message} from '@common/i18n/message';\nimport {chunkArray} from '@common/utils/array/chunk-array';\nimport {AppearanceEditorBreadcrumbItem} from '@common/admin/appearance/types/appearance-editor-section';\n\nexport const DefaultAppearanceConfig: IAppearanceConfig = {\n preview: {\n defaultRoute: '/',\n navigationRoutes: [],\n },\n sections: {\n general: {\n label: message('General'),\n position: 1,\n buildBreadcrumb: () => [\n {\n label: message('General'),\n location: `general`,\n },\n ],\n },\n themes: {\n label: message('Themes'),\n position: 2,\n buildBreadcrumb: (pathname, formValue) => {\n const parts = pathname.split('/').filter(p => !!p);\n const [, , , themeIndex] = parts;\n const breadcrumb: AppearanceEditorBreadcrumbItem[] = [\n {\n label: message('Themes'),\n location: `themes`,\n },\n ];\n if (themeIndex != null) {\n breadcrumb.push({\n label: formValue.appearance.themes.all[+themeIndex]?.name,\n location: `themes/${themeIndex}`,\n });\n }\n if (parts.at(-1) === 'font') {\n breadcrumb.push({\n label: message('Font'),\n location: `themes/${themeIndex}/font`,\n });\n }\n if (parts.at(-1) === 'radius') {\n breadcrumb.push({\n label: message('Rounding'),\n location: `themes/${themeIndex}/radius`,\n });\n }\n return breadcrumb;\n },\n },\n menus: {\n label: message('Menus'),\n position: 3,\n buildBreadcrumb: (pathname, formValue) => {\n // /admin/appearance/menus/0/items/1\n const parts = pathname.split('/').filter(p => !!p);\n const [, , ...rest] = parts;\n // admin/appearance\n const breadcrumb: AppearanceEditorBreadcrumbItem[] = [\n {\n label: message('Menus'),\n location: 'menus',\n },\n ];\n // chunk every two items: [form group, item index]\n const chunked = chunkArray(rest, 2);\n chunked.forEach(([sectionName, sectionIndex], chunkIndex) => {\n // menu\n if (sectionName === 'menus' && sectionIndex != null) {\n breadcrumb.push({\n label: formValue.settings.menus[+sectionIndex]?.name,\n location: `menus/${sectionIndex}`,\n });\n // menu item\n } else if (sectionName === 'items' && sectionIndex != null) {\n const [, menuIndex] = chunked[chunkIndex - 1];\n breadcrumb.push({\n label:\n formValue.settings.menus[+menuIndex].items[+sectionIndex]\n ?.label,\n location: `menus/${menuIndex}/${sectionIndex}`,\n });\n }\n });\n return breadcrumb;\n },\n config: {\n availableRoutes: [\n '/',\n '/login',\n '/register',\n '/contact',\n '/pricing',\n '/account-settings',\n '/admin',\n '/admin/appearance',\n '/admin/settings',\n '/admin/plans',\n '/admin/subscriptions',\n '/admin/users',\n '/admin/roles',\n '/admin/pages',\n '/admin/tags',\n '/admin/files',\n '/admin/localizations',\n '/admin/ads',\n '/admin/settings/authentication',\n '/admin/settings/branding',\n '/admin/settings/cache',\n '/admin/settings/providers',\n '/api-docs',\n ],\n positions: [\n 'admin-navbar',\n 'admin-sidebar',\n 'custom-page-navbar',\n 'auth-page-footer',\n 'auth-dropdown',\n 'account-settings-page',\n 'billing-page',\n 'checkout-page-navbar',\n 'checkout-page-footer',\n 'pricing-table-page',\n 'contact-us-page',\n 'notifications-page',\n 'footer',\n 'footer-secondary',\n ],\n } as MenuSectionConfig,\n },\n 'custom-code': {\n label: message('Custom Code'),\n position: 4,\n buildBreadcrumb: () => [\n {\n label: message('Custom code'),\n location: `custom-code`,\n },\n ],\n },\n 'seo-settings': {\n label: message('SEO Settings'),\n position: 5,\n buildBreadcrumb: () => [\n {\n label: message('SEO'),\n location: `seo`,\n },\n ],\n },\n },\n};\n","import clsx from 'clsx';\nimport {forwardRef, ReactNode} from 'react';\nimport {KeyboardArrowRightIcon} from '../../icons/material/KeyboardArrowRight';\nimport {ButtonBase, ButtonBaseProps} from '../../ui/buttons/button-base';\n\ninterface Props extends ButtonBaseProps {\n startIcon?: ReactNode;\n description?: ReactNode;\n}\nexport const AppearanceButton = forwardRef(\n ({startIcon, children, className, description, ...other}, ref) => {\n return (\n \n {startIcon}\n \n {children}\n {description && (\n \n {description}\n \n )}\n \n \n \n );\n },\n);\n","import {createSvgIcon} from '../../../../icons/create-svg-icon';\n\nexport const ColorIcon = createSvgIcon(\n \n);\n","import React from 'react';\nimport clsx from 'clsx';\nimport {ButtonBase} from '../buttons/button-base';\n\ntype Props = {\n onChange?: (e: string) => void;\n value?: string;\n colors: string[];\n};\nexport function ColorSwatch({onChange, value, colors}: Props) {\n const presetButtons = colors.map(color => {\n const isSelected = value === color;\n return (\n {\n onChange?.(color);\n }}\n className={clsx(\n 'relative block flex-shrink-0 w-26 h-26 border rounded',\n isSelected && 'shadow-md'\n )}\n style={{backgroundColor: color}}\n >\n {isSelected && (\n \n )}\n \n );\n });\n\n return
{presetButtons}
;\n}\n","import {message} from '@common/i18n/message';\nimport {MessageDescriptor} from '@common/i18n/message-descriptor';\n\nexport const ColorPresets: {\n color: string;\n name: MessageDescriptor;\n foreground?: string;\n}[] = [\n {\n color: 'rgb(255, 255, 255)',\n name: message('White'),\n },\n {\n color: 'rgb(239,245,245)',\n name: message('Solitude'),\n },\n {\n color: 'rgb(245,213,174)',\n name: message('Wheat'),\n },\n {\n color: 'rgb(253,227,167)',\n name: message('Cape Honey'),\n },\n {\n color: 'rgb(242,222,186)',\n name: message('Milk punch'),\n },\n {\n color: 'rgb(97,118,75)',\n name: message('Dingy'),\n foreground: 'rgb(255, 255, 255)',\n },\n {\n color: 'rgb(4, 147, 114)',\n name: message('Aquamarine'),\n foreground: 'rgb(255, 255, 255)',\n },\n {\n color: 'rgb(222,245,229)',\n name: message('Cosmic Latte'),\n },\n {\n color: 'rgb(233,119,119)',\n name: message('Geraldine'),\n foreground: 'rgb(90,14,14)',\n },\n {\n color: 'rgb(247,164,164)',\n name: message('Sundown'),\n },\n {\n color: 'rgb(30,139,195)',\n name: message('Pelorous'),\n foreground: 'rgb(255, 255, 255)',\n },\n {\n color: 'rgb(142,68,173)',\n name: message('Deep Lilac'),\n foreground: 'rgb(255, 255, 255)',\n },\n {\n color: 'rgb(108,74,182)',\n name: message('Blue marguerite'),\n foreground: 'rgb(255, 255, 255)',\n },\n {\n color: 'rgb(139,126,116)',\n name: message('Americano'),\n foreground: 'rgb(255, 255, 255)',\n },\n {\n color: 'rgb(0,0,0)',\n name: message('Black'),\n foreground: 'rgb(255, 255, 255)',\n },\n {\n color: 'rgb(64,66,88)',\n name: message('Blue zodiac'),\n foreground: 'rgb(255, 255, 255)',\n },\n {\n color: 'rgb(101,100,124)',\n name: message('Comet'),\n foreground: 'rgb(255, 255, 255)',\n },\n];\n","import {HexColorInput, HexColorPicker} from 'react-colorful';\nimport React, {useState} from 'react';\nimport {parseColor} from '@react-stately/color';\nimport {ColorSwatch} from './color-swatch';\nimport {getInputFieldClassNames} from '../forms/input-field/get-input-field-class-names';\nimport {ColorPresets} from '@common/ui/color-picker/color-presets';\n\nconst DefaultPresets = ColorPresets.map(({color}) => color).slice(0, 14);\n\ntype Props = {\n defaultValue?: string;\n onChange?: (e: string) => void;\n colorPresets?: string[];\n showInput?: boolean;\n};\nexport function ColorPicker({\n defaultValue,\n onChange,\n colorPresets,\n showInput,\n}: Props) {\n const [color, setColor] = useState(defaultValue);\n\n const presets: string[] = colorPresets || DefaultPresets;\n\n const style = getInputFieldClassNames({size: 'sm'});\n\n return (\n
\n {\n onChange?.(newColor);\n setColor(newColor);\n }}\n />\n
\n {presets && (\n {\n if (newColor) {\n const hex = parseColor(newColor).toString('hex');\n onChange?.(hex);\n setColor(hex);\n }\n }}\n value={color}\n />\n )}\n {showInput && (\n
\n {\n onChange?.(newColor);\n setColor(newColor);\n }}\n />\n
\n )}\n
\n
\n );\n}\n","import {ColorPicker} from './color-picker';\nimport {DialogFooter} from '../overlays/dialog/dialog-footer';\nimport {Button} from '../buttons/button';\nimport {useDialogContext} from '../overlays/dialog/dialog-context';\nimport {Dialog} from '../overlays/dialog/dialog';\nimport {Trans} from '../../i18n/trans';\n\ninterface ColorPickerDialogProps {\n hideFooter?: boolean;\n showInput?: boolean;\n}\nexport function ColorPickerDialog({\n hideFooter = false,\n showInput = true,\n}: ColorPickerDialogProps) {\n const {close, value, setValue, initialValue} = useDialogContext<\n string | null\n >();\n // todo: remove this once pixie and bedrive are refactored to use dialogTrigger currentValue (use \"currentValue\" for defaultValue as well)\n //const initialValue = useRef(defaultValue);\n\n return (\n \n setValue(newValue)}\n />\n {!hideFooter && (\n \n \n close(value)}\n >\n \n \n \n )}\n \n );\n}\n","import {useFormContext} from 'react-hook-form';\nimport {\n appearanceState,\n AppearanceValues,\n useAppearanceStore,\n} from '@common/admin/appearance/appearance-store';\nimport {Fragment, ReactNode} from 'react';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {Trans} from '@common/i18n/trans';\nimport {FormImageSelector} from '@common/ui/images/image-selector';\nimport {FormSlider} from '@common/ui/forms/slider/slider';\nimport {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';\nimport {AppearanceButton} from '@common/admin/appearance/appearance-button';\nimport {ColorIcon} from '@common/admin/appearance/sections/themes/color-icon';\nimport {ColorPickerDialog} from '@common/ui/color-picker/color-picker-dialog';\nimport {Link} from 'react-router-dom';\nimport {FormSwitch} from '@common/ui/forms/toggle/switch';\nimport {LandingPageContent} from '@app/landing-page/landing-page-content';\n\nexport function LandingPageSectionGeneral() {\n return (\n \n \n
\n \n \n \n \n \n \n \n \n \n
\n \n \n
\n );\n}\n\nfunction HeaderSection() {\n const defaultImage = useAppearanceStore(\n s => s.defaults?.settings.homepage?.appearance?.headerImage,\n );\n\n return (\n \n }\n className=\"mb-20\"\n name=\"settings.homepage.appearance.headerTitle\"\n onFocus={() => {\n appearanceState().preview.setHighlight('[data-testid=\"headerTitle\"]');\n }}\n />\n }\n className=\"mb-30\"\n inputElementType=\"textarea\"\n rows={4}\n name=\"settings.homepage.appearance.headerSubtitle\"\n onFocus={() => {\n appearanceState().preview.setHighlight(\n '[data-testid=\"headerSubtitle\"]',\n );\n }}\n />\n }\n defaultValue={defaultImage}\n diskPrefix=\"homepage\"\n />\n \n \n \n }\n minValue={0}\n step={0.1}\n maxValue={1}\n formatOptions={{style: 'percent'}}\n />\n
\n \n
\n }\n />\n }\n />\n
\n );\n}\n\nfunction FooterSection() {\n const defaultImage = useAppearanceStore(\n s =>\n (s.defaults?.settings.homepage?.appearance as LandingPageContent)\n ?.footerImage,\n );\n return (\n \n \n \n \n }\n className=\"mb-20\"\n name=\"settings.homepage.appearance.footerTitle\"\n onFocus={() => {\n appearanceState().preview.setHighlight('[data-testid=\"footerTitle\"]');\n }}\n />\n }\n className=\"mb-20\"\n name=\"settings.homepage.appearance.footerSubtitle\"\n onFocus={() => {\n appearanceState().preview.setHighlight(\n '[data-testid=\"footerSubtitle\"]',\n );\n }}\n />\n }\n defaultValue={defaultImage}\n diskPrefix=\"homepage\"\n />\n \n );\n}\n\nfunction PricingSection() {\n return (\n
\n }\n className=\"mb-20\"\n name=\"settings.homepage.appearance.pricingTitle\"\n onFocus={() => {\n appearanceState().preview.setHighlight(\n '[data-testid=\"pricingTitle\"]',\n );\n }}\n />\n }\n className=\"mb-20\"\n name=\"settings.homepage.appearance.pricingSubtitle\"\n onFocus={() => {\n appearanceState().preview.setHighlight(\n '[data-testid=\"pricingSubtitle\"]',\n );\n }}\n />\n \n \n \n
\n );\n}\n\ninterface ColorPickerTriggerProps {\n formKey: string;\n label: ReactNode;\n}\nfunction ColorPickerTrigger({label, formKey}: ColorPickerTriggerProps) {\n const key = formKey as 'settings.homepage.appearance.headerOverlayColor1';\n const {watch, setValue} = useFormContext();\n\n const formValue = watch(key);\n\n const setColor = (value: string | null) => {\n setValue(formKey as any, value, {\n shouldDirty: true,\n });\n };\n\n return (\n setColor(newValue)}\n type=\"popover\"\n onClose={value => setColor(value)}\n >\n \n }\n >\n {label}\n \n \n \n );\n}\n","export function ucFirst(string: T): T {\n if (!string) return string;\n return (string.charAt(0).toUpperCase() + string.slice(1)) as T;\n}\n","import {useControlledState} from '@react-stately/utils';\nimport React, {Fragment, useState} from 'react';\nimport {useController} from 'react-hook-form';\nimport {mergeProps} from '@react-aria/utils';\nimport clsx from 'clsx';\nimport {produce} from 'immer';\nimport {Permission, PermissionRestriction} from '../permission';\nimport {useValueLists} from '../../http/value-lists';\nimport {ucFirst} from '../../utils/string/uc-first';\nimport {Accordion, AccordionItem} from '../../ui/accordion/accordion';\nimport {List, ListItem} from '../../ui/list/list';\nimport {Switch} from '../../ui/forms/toggle/switch';\nimport {TextField} from '../../ui/forms/input-field/text-field/text-field';\nimport {DoneAllIcon} from '../../icons/material/DoneAll';\nimport {Trans} from '../../i18n/trans';\n\ninterface PermissionSelectorProps {\n value?: Permission[];\n onChange?: (value: Permission[]) => void;\n valueListKey?: 'permissions' | 'workspacePermissions';\n}\nexport const PermissionSelector = React.forwardRef<\n HTMLDivElement,\n PermissionSelectorProps\n>(({valueListKey = 'permissions', ...props}, ref) => {\n const {data} = useValueLists([valueListKey]);\n const permissions = data?.permissions || data?.workspacePermissions;\n\n const [value, setValue] = useControlledState(props.value, [], props.onChange);\n const [showAdvanced, setShowAdvanced] = useState(false);\n\n if (!permissions) return null;\n\n const groupedPermissions = buildPermissionList(\n permissions,\n value,\n showAdvanced\n );\n\n const onRestrictionChange = (newPermission: Permission) => {\n const newValue = [...value];\n const index = newValue.findIndex(p => p.id === newPermission.id);\n if (index > -1) {\n newValue.splice(index, 1, newPermission);\n }\n setValue(newValue);\n };\n\n return (\n \n \n {groupedPermissions.map(({groupName, items, anyChecked}) => (\n }\n key={groupName}\n startIcon={anyChecked ? : undefined}\n >\n \n {items.map(permission => {\n const index = value.findIndex(v => v.id === permission.id);\n const isChecked = index > -1;\n\n return (\n
\n {\n if (isChecked) {\n const newValue = [...value];\n newValue.splice(index, 1);\n setValue(newValue);\n } else {\n setValue([...value, permission]);\n }\n }}\n endSection={\n {}}\n />\n }\n description={}\n >\n \n \n {isChecked && (\n \n )}\n
\n );\n })}\n
\n \n ))}\n
\n {\n setShowAdvanced(e.target.checked);\n }}\n >\n \n \n
\n );\n});\n\ninterface RestrictionsProps {\n permission: Permission;\n onChange?: (newPermission: Permission) => void;\n}\nfunction Restrictions({permission, onChange}: RestrictionsProps) {\n if (!permission?.restrictions?.length) return null;\n\n const setRestrictionValue = (\n name: string,\n value: PermissionRestriction['value']\n ) => {\n const nextState = produce(permission, draftState => {\n const restriction = draftState.restrictions.find(r => r.name === name);\n if (restriction) {\n restriction.value = value;\n }\n });\n onChange?.(nextState);\n };\n\n return (\n
\n {permission.restrictions.map((restriction, index) => {\n const isLast = index === permission.restrictions.length - 1;\n\n const name = ;\n const description = restriction.description ? (\n \n ) : undefined;\n\n if (restriction.type === 'bool') {\n return (\n {\n setRestrictionValue(restriction.name, e.target.checked);\n }}\n >\n {name}\n \n );\n }\n\n return (\n {\n setRestrictionValue(\n restriction.name,\n e.target.value === '' ? undefined : parseInt(e.target.value)\n );\n }}\n />\n );\n })}\n
\n );\n}\n\nexport type FormChipFieldProps = PermissionSelectorProps & {\n name: string;\n};\nexport function FormPermissionSelector(props: FormChipFieldProps) {\n const {\n field: {onChange, value = [], ref},\n } = useController({\n name: props.name,\n });\n\n const formProps: Partial = {\n onChange,\n value,\n };\n\n return ;\n}\n\nexport const prettyName = (name: string) => {\n return ucFirst(name.replace('_', ' '));\n};\n\ninterface PermissionGroup {\n groupName: string;\n anyChecked: boolean;\n items: Permission[];\n}\n\n// merge \"restrictions\" from selected value into all permissions to make\n// it easier to bind restriction values to form inputs\nexport function buildPermissionList(\n allPermissions: Permission[],\n selectedPermissions: Permission[],\n showAdvanced: boolean\n) {\n const groupedPermissions: PermissionGroup[] = [];\n\n allPermissions.forEach(permission => {\n const index = selectedPermissions.findIndex(p => p.id === permission.id);\n if (!showAdvanced && permission.advanced) return;\n\n let group: PermissionGroup | undefined = groupedPermissions.find(\n g => g.groupName === permission.group\n );\n if (!group) {\n group = {groupName: permission.group, anyChecked: false, items: []};\n groupedPermissions.push(group);\n }\n\n if (index > -1) {\n const mergedPermission = {\n ...permission,\n restrictions: mergeRestrictions(\n permission.restrictions,\n selectedPermissions[index].restrictions\n ),\n };\n group.anyChecked = true;\n group.items.push(mergedPermission);\n } else {\n group.items.push(permission);\n }\n });\n\n return groupedPermissions;\n}\n\nfunction mergeRestrictions(\n allRestrictions: PermissionRestriction[],\n selectedRestrictions: PermissionRestriction[]\n): PermissionRestriction[] {\n return allRestrictions?.map(restriction => {\n const selected = selectedRestrictions.find(\n r => r.name === restriction.name\n );\n if (selected) {\n return {...restriction, value: selected.value};\n } else {\n return restriction;\n }\n });\n}\n","import {MenuSectionConfig} from '../../../types/appearance-editor-config';\nimport {MenuItemConfig} from '../../../../../core/settings/settings';\nimport mergedAppearanceConfig from '../../../config/merged-appearance-config';\n\nexport function useAvailableRoutes(): Partial[] {\n const menuConfig = mergedAppearanceConfig.sections.menus.config;\n\n if (!menuConfig) return [];\n\n return (menuConfig as MenuSectionConfig).availableRoutes.map(route => {\n return {\n id: route,\n label: route,\n action: route,\n type: 'route',\n target: '_self',\n };\n });\n}\n","export const iconGridStyle = {\n grid: 'flex flex-wrap gap-24',\n button:\n 'flex flex-col items-center rounded hover:bg-hover h-90 aspect-square',\n};\n","import React, {Suspense} from 'react';\nimport {IconTree} from '../../icons/create-svg-icon';\nimport {iconGridStyle} from './icon-grid-style';\nimport {TextField} from '../forms/input-field/text-field/text-field';\nimport {Skeleton} from '../skeleton/skeleton';\nimport {useTrans} from '../../i18n/use-trans';\nimport {AnimatePresence, m} from 'framer-motion';\nimport {opacityAnimation} from '../animation/opacity-animation';\n\nconst skeletons = [...Array(60).keys()];\n\nconst IconList = React.lazy(() => import('./icon-list'));\n\ninterface IconListProps {\n onIconSelected: (icon: IconTree[] | null) => void;\n}\nexport default function IconPicker({onIconSelected}: IconListProps) {\n const {trans} = useTrans();\n const [value, setValue] = React.useState('');\n\n return (\n
\n {\n setValue(e.target.value);\n }}\n placeholder={trans({message: 'Search icons...'})}\n />\n \n \n {skeletons.map((_, index) => (\n
\n \n
\n ))}\n \n }\n >\n \n \n \n \n
\n
\n );\n}\n","import React from 'react';\nimport IconPicker from './icon-picker';\nimport {useDialogContext} from '../overlays/dialog/dialog-context';\nimport {Dialog} from '../overlays/dialog/dialog';\nimport {DialogHeader} from '../overlays/dialog/dialog-header';\nimport {DialogBody} from '../overlays/dialog/dialog-body';\nimport {Trans} from '../../i18n/trans';\n\nexport function IconPickerDialog() {\n return (\n \n \n \n \n \n \n \n \n );\n}\n\nfunction IconPickerWrapper() {\n const {close} = useDialogContext();\n return (\n {\n close(value);\n }}\n />\n );\n}\n","import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';\nimport {Trans} from '../../i18n/trans';\nimport {useValueLists} from '../../http/value-lists';\nimport {useTrans} from '../../i18n/use-trans';\nimport {FormChipField} from '../../ui/forms/input-field/chip-field/form-chip-field';\nimport {Item} from '../../ui/forms/listbox/item';\nimport {Fragment, useEffect, useMemo} from 'react';\nimport {\n buildPermissionList,\n prettyName,\n} from '../../auth/ui/permission-selector';\nimport {Section} from '../../ui/forms/listbox/section';\nimport {useFormContext} from 'react-hook-form';\nimport {MenuItemConfig} from '../../core/settings/settings';\nimport {FormSelect, Option} from '../../ui/forms/select/select';\nimport {useAvailableRoutes} from '../appearance/sections/menus/hooks/available-routes';\nimport {ButtonBaseProps} from '../../ui/buttons/button-base';\nimport {createSvgIconFromTree, IconTree} from '../../icons/create-svg-icon';\nimport {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';\nimport {IconButton} from '../../ui/buttons/icon-button';\nimport {EditIcon} from '../../icons/material/Edit';\nimport {IconPickerDialog} from '../../ui/icon-picker/icon-picker-dialog';\nimport {message} from '../../i18n/message';\nimport {usePrevious} from '../../utils/hooks/use-previous';\n\ninterface NameProps {\n prefixName: (name: string) => string;\n}\n\ninterface MenuItemFormProps {\n formPathPrefix?: string;\n hideRoleAndPermissionFields?: boolean;\n}\nexport function MenuItemForm({\n formPathPrefix,\n hideRoleAndPermissionFields,\n}: MenuItemFormProps) {\n const {trans} = useTrans();\n const prefixName = (name: string): string => {\n return formPathPrefix ? `${formPathPrefix}.${name}` : name;\n };\n\n return (\n \n }\n placeholder={trans(message('No label...'))}\n startAppend={}\n />\n \n {!hideRoleAndPermissionFields && (\n \n \n \n \n )}\n \n \n );\n}\n\ninterface IconDialogTriggerProps extends ButtonBaseProps, NameProps {}\nfunction IconDialogTrigger({\n prefixName,\n ...buttonProps\n}: IconDialogTriggerProps) {\n const {watch, setValue} = useFormContext();\n const fieldName = prefixName('icon') as 'icon';\n const watchedItemIcon = watch(fieldName);\n const Icon = watchedItemIcon && createSvgIconFromTree(watchedItemIcon);\n return (\n {\n // null will be set explicitly if icon is cleared via icon picker\n if (iconTree || iconTree === null) {\n setValue(fieldName, iconTree, {\n shouldDirty: true,\n });\n }\n }}\n >\n \n {Icon ? : }\n \n \n \n );\n}\n\nfunction DestinationSelector({prefixName}: NameProps) {\n const form = useFormContext();\n const currentType = form.watch(prefixName('type') as 'type');\n const previousType = usePrevious(currentType);\n const {data} = useValueLists(['menuItemCategories']);\n const categories = data?.menuItemCategories || [];\n const selectedCategory = categories.find(c => c.type === currentType);\n const {trans} = useTrans();\n const routeItems = useAvailableRoutes();\n\n // clear \"action\" field when \"type\" field changes\n useEffect(() => {\n if (previousType && previousType !== currentType) {\n form.setValue(prefixName('action') as 'action', '');\n }\n }, [currentType, previousType, form, prefixName]);\n\n return (\n \n }\n >\n \n \n {categories.map(category => (\n \n ))}\n \n {currentType === 'link' && (\n }\n />\n )}\n {currentType === 'route' && (\n }\n searchPlaceholder={trans(message('Search pages'))}\n showSearchField\n selectionMode=\"single\"\n >\n {item => (\n \n {item.label}\n \n )}\n \n )}\n {selectedCategory && (\n }\n >\n {item => (\n \n \n \n )}\n \n )}\n \n );\n}\n\nfunction RoleSelector({prefixName}: NameProps) {\n const {data} = useValueLists(['roles', 'permissions']);\n const roles = data?.roles || [];\n const {trans} = useTrans();\n\n return (\n }\n name={prefixName('roles')}\n chipSize=\"sm\"\n suggestions={roles}\n valueKey=\"id\"\n displayWith={c => roles.find(r => r.id === c.id)?.name}\n >\n {role => (\n \n \n \n )}\n \n );\n}\n\nfunction PermissionSelector({prefixName}: NameProps) {\n const {data} = useValueLists(['roles', 'permissions']);\n const {trans} = useTrans();\n\n const groupedPermissions = useMemo(() => {\n return buildPermissionList(data?.permissions || [], [], false);\n }, [data?.permissions]);\n\n return (\n }\n placeholder={trans({message: 'Add permission...'})}\n chipSize=\"sm\"\n suggestions={groupedPermissions}\n name={prefixName('permissions')}\n valueKey=\"name\"\n >\n {({groupName, items}) => (\n
\n {items.map(permission => (\n }\n >\n \n \n ))}\n
\n )}\n \n );\n}\n\nfunction TargetSelect({prefixName}: NameProps) {\n return (\n }\n >\n \n \n \n );\n}\n","import {MenuItemForm} from '@common/admin/menus/menu-item-form';\nimport {Accordion, AccordionItem} from '@common/ui/accordion/accordion';\nimport {Trans} from '@common/i18n/trans';\nimport {appearanceState} from '@common/admin/appearance/appearance-store';\nimport {useState} from 'react';\n\nexport function LandingPageSectionActionButtons() {\n const [expandedValues, setExpandedValues] = useState(['cta1']);\n return (\n {\n setExpandedValues(values as string[]);\n if (values.length) {\n appearanceState().preview.setHighlight(\n `[data-testid=\"${values[0]}\"]`\n );\n }\n }}\n >\n }>\n \n \n }>\n \n \n }>\n \n \n \n );\n}\n","import {Accordion, AccordionItem} from '@common/ui/accordion/accordion';\nimport {Trans} from '@common/i18n/trans';\nimport {\n appearanceState,\n useAppearanceStore,\n} from '@common/admin/appearance/appearance-store';\nimport {useFieldArray} from 'react-hook-form';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {FormImageSelector} from '@common/ui/images/image-selector';\nimport {Button} from '@common/ui/buttons/button';\nimport {AddIcon} from '@common/icons/material/Add';\nimport {useState} from 'react';\n\nexport function LandingPageSectionPrimaryFeatures() {\n const {fields, remove, append} = useFieldArray({\n name: 'settings.homepage.appearance.primaryFeatures',\n });\n const [expandedValues, setExpandedValues] = useState([0]);\n return (\n
\n {\n setExpandedValues(values as number[]);\n if (values.length) {\n appearanceState().preview.setHighlight(\n `[data-testid=\"primary-root-${values[0]}\"]`\n );\n }\n }}\n >\n {fields.map((field, index) => {\n return (\n }\n >\n \n
\n {\n remove(index);\n }}\n >\n \n \n
\n \n );\n })}\n \n
\n }\n onClick={() => {\n append({});\n setExpandedValues([fields.length]);\n }}\n >\n \n \n
\n
\n );\n}\n\ninterface FeatureFormProps {\n index: number;\n}\nfunction FeatureForm({index}: FeatureFormProps) {\n const defaultImage = useAppearanceStore(\n s =>\n s.defaults?.settings.homepage?.appearance?.primaryFeatures?.[index]?.image\n );\n\n return (\n <>\n }\n defaultValue={defaultImage}\n diskPrefix=\"homepage\"\n />\n }\n className=\"mb-20\"\n onFocus={() => {\n appearanceState().preview.setHighlight(\n `[data-testid=\"primary-title-${index}\"]`\n );\n }}\n />\n }\n className=\"mb-20\"\n inputElementType=\"textarea\"\n rows={4}\n onFocus={() => {\n appearanceState().preview.setHighlight(\n `[data-testid=\"primary-subtitle-${index}\"]`\n );\n }}\n />\n \n );\n}\n","import {Accordion, AccordionItem} from '@common/ui/accordion/accordion';\nimport {Trans} from '@common/i18n/trans';\nimport {appearanceState} from '@common/admin/appearance/appearance-store';\nimport {useFieldArray} from 'react-hook-form';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {FormImageSelector} from '@common/ui/images/image-selector';\nimport {Button} from '@common/ui/buttons/button';\nimport {AddIcon} from '@common/icons/material/Add';\nimport {useState} from 'react';\n\nexport function LandingPageSecondaryFeatures() {\n const {fields, remove, append} = useFieldArray({\n name: 'settings.homepage.appearance.secondaryFeatures',\n });\n const [expandedValues, setExpandedValues] = useState([0]);\n return (\n
\n {\n setExpandedValues(values as number[]);\n if (values.length) {\n appearanceState().preview.setHighlight(\n `[data-testid=\"secondary-root-${values[0]}\"]`\n );\n }\n }}\n >\n {fields.map((field, index) => {\n return (\n }\n >\n \n
\n {\n remove(index);\n }}\n >\n \n \n
\n \n );\n })}\n \n
\n }\n onClick={() => {\n append({});\n setExpandedValues([fields.length]);\n }}\n >\n \n \n
\n
\n );\n}\n\ninterface FeatureFormProps {\n index: number;\n}\nfunction FeatureForm({index}: FeatureFormProps) {\n return (\n <>\n }\n defaultValue={getDefaultImage(index)}\n diskPrefix=\"homepage\"\n />\n }\n className=\"mb-20\"\n onFocus={() => {\n appearanceState().preview.setHighlight(\n `[data-testid=\"secondary-title-${index}\"]`\n );\n }}\n />\n }\n className=\"mb-20\"\n inputElementType=\"textarea\"\n rows={4}\n onFocus={() => {\n appearanceState().preview.setHighlight(\n `[data-testid=\"secondary-subtitle-${index}\"]`\n );\n }}\n />\n }\n className=\"mb-20\"\n inputElementType=\"textarea\"\n rows={4}\n onFocus={() => {\n appearanceState().preview.setHighlight(\n `[data-testid=\"secondary-description-${index}\"]`\n );\n }}\n />\n \n );\n}\n\nfunction getDefaultImage(index: number): string | undefined {\n return appearanceState().defaults?.settings.homepage?.appearance\n .secondaryFeatures[index]?.image;\n}\n","import {\n IAppearanceConfig,\n MenuSectionConfig,\n SeoSettingsSectionConfig,\n} from '@common/admin/appearance/types/appearance-editor-config';\nimport {message} from '@common/i18n/message';\nimport {LandingPageSectionGeneral} from '@app/admin/appearance/sections/landing-page-section/landing-page-section-general';\nimport {LandingPageSectionActionButtons} from '@app/admin/appearance/sections/landing-page-section/landing-page-section-action-buttons';\nimport {LandingPageSectionPrimaryFeatures} from '@app/admin/appearance/sections/landing-page-section/landing-page-section-primary-features';\nimport {LandingPageSecondaryFeatures} from '@app/admin/appearance/sections/landing-page-section/landing-page-section-secondary-features';\nimport {AppearanceEditorBreadcrumbItem} from '@common/admin/appearance/types/appearance-editor-section';\n\nexport const AppAppearanceConfig: IAppearanceConfig = {\n preview: {\n defaultRoute: 'dashboard',\n navigationRoutes: ['dashboard'],\n },\n sections: {\n 'landing-page': {\n label: message('Landing Page'),\n position: 1,\n previewRoute: '/',\n routes: [\n {path: 'landing-page', element: },\n {\n path: 'landing-page/action-buttons',\n element: ,\n },\n {\n path: 'landing-page/primary-features',\n element: ,\n },\n {\n path: 'landing-page/secondary-features',\n element: ,\n },\n ],\n buildBreadcrumb: pathname => {\n const parts = pathname.split('/').filter(p => !!p);\n const sectionName = parts.pop();\n // admin/appearance\n const breadcrumb: AppearanceEditorBreadcrumbItem[] = [\n {\n label: message('Landing page'),\n location: 'landing-page',\n },\n ];\n if (sectionName === 'action-buttons') {\n breadcrumb.push({\n label: message('Action buttons'),\n location: 'landing-page/action-buttons',\n });\n }\n\n if (sectionName === 'primary-features') {\n breadcrumb.push({\n label: message('Primary features'),\n location: 'landing-page/primary-features',\n });\n }\n\n if (sectionName === 'secondary-features') {\n breadcrumb.push({\n label: message('Secondary features'),\n location: 'landing-page/secondary-features',\n });\n }\n\n return breadcrumb;\n },\n },\n // missing label will get added by deepMerge from default config\n // @ts-ignore\n menus: {\n config: {\n positions: [\n 'sidebar-primary',\n 'sidebar-secondary',\n 'mobile-bottom',\n 'landing-page-navbar',\n 'landing-page-footer',\n ],\n availableRoutes: [\n '/lists',\n '/watchlist',\n '/admin/channels',\n '/admin/comments',\n ],\n } as MenuSectionConfig,\n },\n // @ts-ignore\n 'seo-settings': {\n config: {\n pages: [\n {\n key: 'title-page',\n label: message('Title page'),\n },\n {\n key: 'season-page',\n label: message('Season page'),\n },\n {\n key: 'episode-page',\n label: message('Episode page'),\n },\n {\n key: 'watch-page',\n label: message('Watch page'),\n },\n {\n key: 'person-page',\n label: message('Person page'),\n },\n {\n key: 'landing-page',\n label: message('Landing page'),\n },\n {\n key: 'news-article-page',\n label: message('News article page'),\n },\n {\n key: 'channel-page',\n label: message('Channel page'),\n },\n ],\n } as SeoSettingsSectionConfig,\n },\n },\n};\n","import deepMerge from 'deepmerge';\nimport {DefaultAppearanceConfig} from '@common/admin/appearance/config/default-appearance-config';\nimport {AppAppearanceConfig} from '@app/admin/appearance/app-appearance-config';\nimport {IAppearanceConfig} from '@common/admin/appearance/types/appearance-editor-config';\n\nconst mergedAppearanceConfig = deepMerge.all([\n DefaultAppearanceConfig,\n AppAppearanceConfig,\n]);\n\nexport default mergedAppearanceConfig as IAppearanceConfig;\n","import {create} from 'zustand';\nimport {subscribeWithSelector} from 'zustand/middleware';\nimport {immer} from 'zustand/middleware/immer';\nimport {Settings} from '../../core/settings/settings';\nimport type {IAppearanceConfig} from './types/appearance-editor-config';\nimport {AllCommands} from './commands/commands';\nimport mergedAppearanceConfig from './config/merged-appearance-config';\nimport {BootstrapData} from '../../core/bootstrap-data/bootstrap-data';\nimport {FontConfig} from '@common/http/value-lists';\n\nexport interface AppearanceValues {\n appearance: {\n env: {app_name: string; app_url: string};\n seo: {\n key: string;\n name: string;\n value: string;\n defaultValue: string;\n }[];\n themes: BootstrapData['themes'];\n custom_code: {\n css?: string;\n html?: string;\n };\n };\n settings: Settings;\n}\n\nexport interface AppearanceDefaults {\n appearance: {\n themes: {\n light: Record;\n dark: Record;\n };\n };\n settings: Settings;\n}\n\ninterface AppearanceStore {\n defaults: AppearanceDefaults | null;\n iframeWindow: Window | null;\n config: IAppearanceConfig | null;\n setDefaults: (value: AppearanceDefaults) => void;\n setIframeWindow: (value: Window) => void;\n preview: {\n navigate: (sectionName: string) => void;\n setValues: (settings: AppearanceValues) => void;\n setThemeFont: (font: FontConfig | null) => void;\n setThemeValue: (name: string, value: string) => void;\n setActiveTheme: (themeId: number | string) => void;\n setHighlight: (selector: string | null | undefined) => void;\n setCustomCode: (mode: 'css' | 'html', value?: string) => void;\n };\n}\n\nexport const useAppearanceStore = create()(\n subscribeWithSelector(\n immer((set, get) => ({\n defaults: null,\n iframeWindow: null,\n config: mergedAppearanceConfig,\n setDefaults: value => {\n set(state => {\n state.defaults = {...value};\n });\n },\n setIframeWindow: value => {\n set(() => {\n return {iframeWindow: value};\n });\n },\n\n preview: {\n navigate: sectionName => {\n const section = get().config?.sections[sectionName];\n const route = section?.previewRoute || '/';\n const preview = get().iframeWindow;\n if (route) {\n postMessage(preview, {type: 'navigate', to: route});\n }\n },\n setValues: values => {\n const preview = get().iframeWindow;\n postMessage(preview, {type: 'setValues', values});\n },\n setThemeFont: font => {\n const preview = get().iframeWindow;\n postMessage(preview, {type: 'setThemeFont', value: font});\n },\n setThemeValue: (name, value) => {\n const preview = get().iframeWindow;\n postMessage(preview, {type: 'setThemeValue', name, value});\n },\n setActiveTheme: themeId => {\n const preview = get().iframeWindow;\n postMessage(preview, {type: 'setActiveTheme', themeId});\n },\n setCustomCode: (mode, value) => {\n const preview = get().iframeWindow;\n postMessage(preview, {type: 'setCustomCode', mode, value});\n },\n setHighlight: selector => {\n set(() => {\n let node: HTMLElement | null = null;\n const document = get().iframeWindow?.document;\n if (document && selector) {\n node = document.querySelector(selector);\n }\n if (node) {\n requestAnimationFrame(() => {\n if (!node) return;\n node.scrollIntoView({\n behavior: 'smooth',\n block: 'center',\n inline: 'center',\n });\n });\n }\n });\n },\n },\n })),\n ),\n);\n\nfunction postMessage(window: Window | null, command: AllCommands) {\n if (window) {\n window.postMessage({source: 'be-appearance-editor', ...command}, '*');\n }\n}\n\nexport function appearanceState() {\n return useAppearanceStore.getState();\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {AppearanceValues} from '@common/admin/appearance/appearance-store';\nimport {toast} from '@common/ui/toast/toast';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';\nimport {message} from '@common/i18n/message';\n\ninterface Response extends BackendResponse {}\n\nexport function useSaveAppearanceChanges() {\n return useMutation({\n mutationFn: (values: Partial) =>\n saveAppearanceChanges(values),\n onSuccess: async () => {\n await queryClient.invalidateQueries({\n queryKey: ['admin/appearance/values'],\n });\n toast(message('Changes saved'));\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction saveAppearanceChanges(\n changes: Partial,\n): Promise {\n return apiClient.post(`admin/appearance`, {changes}).then(r => r.data);\n}\n","import {useQuery} from '@tanstack/react-query';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {apiClient} from '@common/http/query-client';\nimport {AppearanceDefaults, AppearanceValues} from '../appearance-store';\n\nexport interface FetchAppearanceValuesResponse extends BackendResponse {\n values: AppearanceValues;\n defaults: AppearanceDefaults;\n}\n\nexport function useAppearanceValues() {\n return useQuery({\n queryKey: ['admin/appearance/values'],\n queryFn: () => fetchAppearanceValues(),\n staleTime: Infinity,\n });\n}\n\nfunction fetchAppearanceValues(): Promise {\n return apiClient\n .get('admin/appearance/values')\n .then(response => response.data);\n}\n","import {Link, useLocation} from 'react-router-dom';\nimport clsx from 'clsx';\nimport {Fragment, useEffect, useState} from 'react';\nimport {IconButton} from '../../ui/buttons/icon-button';\nimport {KeyboardArrowLeftIcon} from '../../icons/material/KeyboardArrowLeft';\nimport {KeyboardArrowRightIcon} from '../../icons/material/KeyboardArrowRight';\nimport {Trans} from '../../i18n/trans';\nimport {MixedText} from '../../i18n/mixed-text';\nimport {useFormContext} from 'react-hook-form';\nimport {appearanceState, AppearanceValues} from './appearance-store';\nimport {AppearanceEditorBreadcrumbItem} from './types/appearance-editor-section';\nimport {message} from '../../i18n/message';\n\nexport function SectionHeader() {\n const {pathname} = useLocation();\n const {getValues} = useFormContext();\n const [breadcrumb, setBreadcrumb] = useState<\n AppearanceEditorBreadcrumbItem[] | null\n >(null);\n\n useEffect(() => {\n const [, , sectionName] = pathname.split('/').filter(p => !!p);\n if (sectionName) {\n const section = appearanceState().config?.sections[sectionName];\n if (section) {\n setBreadcrumb([\n {\n label: message('Appearance'),\n location: '',\n },\n ...section.buildBreadcrumb(pathname, getValues()),\n ]);\n // bail, so breadcrumb is not cleared below\n return;\n }\n }\n setBreadcrumb(null);\n }, [pathname, getValues]);\n\n // not need to show section header if already at root\n if (!breadcrumb || breadcrumb.length < 2) {\n return null;\n }\n\n return (\n
\n \n \n \n
\n
\n \n
\n
\n {breadcrumb.map((item, index) => {\n const isLast = breadcrumb.length - 1 === index;\n const isFirst = index === 0;\n const label = ;\n\n if (isFirst) {\n return null;\n }\n\n return (\n \n \n {label}\n
\n {!isLast && (\n \n )}\n \n );\n })}\n
\n
\n \n );\n}\n","import {Link, Navigate, Outlet, useLocation} from 'react-router-dom';\nimport {useEffect, useRef} from 'react';\nimport {IconButton} from '../../ui/buttons/icon-button';\nimport {CloseIcon} from '../../icons/material/Close';\nimport {Button} from '../../ui/buttons/button';\nimport {appearanceState, AppearanceValues} from './appearance-store';\nimport {useSaveAppearanceChanges} from './requests/save-appearance-changes';\nimport {useAppearanceValues} from './requests/appearance-values';\nimport {Trans} from '../../i18n/trans';\nimport {useForm, useFormContext} from 'react-hook-form';\nimport {Form} from '../../ui/forms/form';\nimport {ProgressCircle} from '../../ui/progress/progress-circle';\nimport {SectionHeader} from './section-header';\nimport {FileUploadProvider} from '../../uploads/uploader/file-upload-provider';\nimport {useAppearanceEditorMode} from './commands/use-appearance-editor-mode';\nimport {StaticPageTitle} from '../../seo/static-page-title';\nimport {useSettings} from '../../core/settings/use-settings';\n\nexport function AppearanceLayout() {\n const {isAppearanceEditorActive} = useAppearanceEditorMode();\n const {data} = useAppearanceValues();\n const {base_url} = useSettings();\n const iframeRef = useRef(null);\n const {pathname} = useLocation();\n\n useEffect(() => {\n // only set defaults snapshot once on route init\n if (data?.defaults && !appearanceState().defaults) {\n appearanceState().setDefaults(data.defaults);\n }\n }, [data]);\n\n useEffect(() => {\n if (iframeRef.current) {\n appearanceState().setIframeWindow(iframeRef.current.contentWindow!);\n }\n }, []);\n\n useEffect(() => {\n const sectionName = pathname.split('/')[3];\n appearanceState().preview.navigate(sectionName);\n }, [pathname]);\n\n // make sure appearance editor iframe can't be nested\n if (isAppearanceEditorActive) {\n return ;\n }\n\n return (\n
\n \n \n \n \n
\n \n
\n
\n );\n}\n\ninterface SidebarProps {\n values: AppearanceValues | undefined;\n}\nfunction Sidebar({values}: SidebarProps) {\n const spinner = (\n
\n \n
\n );\n\n return (\n
\n {values ? : spinner}\n
\n );\n}\n\ninterface AppearanceFormProps {\n defaultValues: AppearanceValues;\n}\n\nfunction AppearanceForm({defaultValues}: AppearanceFormProps) {\n const form = useForm({defaultValues});\n const {watch, reset} = form;\n const saveChanges = useSaveAppearanceChanges();\n\n useEffect(() => {\n const subscription = watch(value => {\n appearanceState().preview.setValues(value as AppearanceValues);\n });\n return () => subscription.unsubscribe();\n }, [watch]);\n\n return (\n {\n saveChanges.mutate(values, {\n onSuccess: () => reset(values),\n });\n }}\n >\n
\n \n
\n \n \n \n
\n \n );\n}\n\ninterface HeaderProps {\n isLoading: boolean;\n}\nfunction Header({isLoading}: HeaderProps) {\n const {\n formState: {dirtyFields},\n } = useFormContext();\n const isDirty = Object.keys(dirtyFields).length;\n return (\n
\n \n \n \n
\n \n
\n \n {isDirty ? : }\n \n
\n );\n}\n","import {Link, useNavigate} from 'react-router-dom';\nimport {AppearanceValues} from '../../appearance-store';\nimport {Button} from '../../../../ui/buttons/button';\nimport {AddIcon} from '../../../../icons/material/Add';\nimport {Trans} from '../../../../i18n/trans';\nimport {useFieldArray} from 'react-hook-form';\nimport {AppearanceButton} from '../../appearance-button';\nimport {nanoid} from 'nanoid';\nimport {useTrans} from '../../../../i18n/use-trans';\nimport {message} from '../../../../i18n/message';\nimport {Fragment} from 'react';\n\nexport function MenuList() {\n const navigate = useNavigate();\n const {trans} = useTrans();\n const {fields, append} = useFieldArray<\n AppearanceValues,\n 'settings.menus',\n 'key'\n >({\n name: 'settings.menus',\n keyName: 'key',\n });\n\n return (\n \n
\n {fields.map((field, index) => (\n \n {field.name}\n \n ))}\n
\n
\n }\n size=\"xs\"\n onClick={() => {\n const id = nanoid(10);\n append({\n name: trans(\n message('New menu :number', {\n values: {number: fields.length + 1},\n })\n ),\n id,\n positions: [],\n items: [],\n });\n navigate(`${fields.length}`);\n }}\n >\n \n \n
\n
\n );\n}\n","import {useForm} from 'react-hook-form';\nimport {Accordion, AccordionItem} from '@common/ui/accordion/accordion';\nimport {Form} from '@common/ui/forms/form';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {MenuItemConfig} from '@common/core/settings/settings';\nimport {AddIcon} from '@common/icons/material/Add';\nimport {Button} from '@common/ui/buttons/button';\nimport {useAvailableRoutes} from '@common/admin/appearance/sections/menus/hooks/available-routes';\nimport {ucFirst} from '@common/utils/string/uc-first';\nimport {List, ListItem} from '@common/ui/list/list';\nimport {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';\nimport {Dialog} from '@common/ui/overlays/dialog/dialog';\nimport {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';\nimport {DialogBody} from '@common/ui/overlays/dialog/dialog-body';\nimport {Trans} from '@common/i18n/trans';\nimport {useValueLists} from '@common/http/value-lists';\nimport {ReactNode} from 'react';\nimport {nanoid} from 'nanoid';\n\ninterface AddMenuItemDialogProps {\n title?: ReactNode;\n}\nexport function AddMenuItemDialog({\n title = ,\n}: AddMenuItemDialogProps) {\n const {data} = useValueLists(['menuItemCategories']);\n const categories = data?.menuItemCategories || [];\n const routeItems = useAvailableRoutes();\n\n return (\n \n {title}\n \n \n }\n bodyClassName=\"max-h-240 overflow-y-auto\"\n >\n \n \n }\n bodyClassName=\"max-h-240 overflow-y-auto\"\n >\n \n \n {categories.map(category => (\n }\n >\n \n \n ))}\n \n \n \n );\n}\n\nfunction AddCustomLink() {\n const form = useForm({\n defaultValues: {\n id: nanoid(6),\n type: 'link',\n target: '_blank',\n },\n });\n const {close} = useDialogContext();\n\n return (\n {\n close(value);\n }}\n >\n }\n className=\"mb-20\"\n />\n }\n className=\"mb-20\"\n />\n
\n \n
\n \n );\n}\n\ninterface AddRouteProps {\n items: Partial[];\n}\nfunction AddRoute({items}: AddRouteProps) {\n const {close} = useDialogContext();\n\n return (\n \n {items.map(item => {\n return (\n }\n onSelected={() => {\n if (item.label) {\n const last = item.label.split('/').pop();\n item.label = last ? ucFirst(last) : item.label;\n item.id = nanoid(6);\n }\n close(item);\n }}\n >\n {item.label}\n \n );\n })}\n \n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const DragIndicatorIcon = createSvgIcon(\n \n, 'DragIndicatorOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const DeleteIcon = createSvgIcon(\n \n, 'DeleteOutlined');\n","export default \"__VITE_ASSET__abcb02f6__\"","import {\n FieldArrayWithId,\n useFieldArray,\n UseFieldArrayReturn,\n useFormContext,\n} from 'react-hook-form';\nimport {Fragment, useEffect, useMemo, useRef} from 'react';\nimport {Link, useNavigate, useParams} from 'react-router-dom';\nimport {MenuSectionConfig} from '../../types/appearance-editor-config';\nimport {MenuItemConfig} from '@common/core/settings/settings';\nimport {\n appearanceState,\n AppearanceValues,\n useAppearanceStore,\n} from '../../appearance-store';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {Button} from '@common/ui/buttons/button';\nimport {AddMenuItemDialog} from '@common/admin/appearance/sections/menus/add-menu-item-dialog';\nimport {AppearanceButton} from '@common/admin/appearance/appearance-button';\nimport {AddIcon} from '@common/icons/material/Add';\nimport {DragIndicatorIcon} from '@common/icons/material/DragIndicator';\nimport {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';\nimport {IllustratedMessage} from '@common/ui/images/illustrated-message';\nimport {SvgImage} from '@common/ui/images/svg-image/svg-image';\nimport {DeleteIcon} from '@common/icons/material/Delete';\nimport {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';\nimport {Option} from '../../../../ui/forms/select/select';\nimport {Trans} from '@common/i18n/trans';\nimport dropdownMenu from './dropdown-menu.svg';\nimport {FormChipField} from '@common/ui/forms/input-field/chip-field/form-chip-field';\nimport {\n useSortable,\n UseSortableProps,\n} from '@common/ui/interactions/dnd/sortable/use-sortable';\nimport {IconButton} from '@common/ui/buttons/icon-button';\nimport {createSvgIconFromTree} from '@common/icons/create-svg-icon';\nimport {useSettings} from '@common/core/settings/use-settings';\n\nexport function MenuEditor() {\n const {menuIndex} = useParams();\n const navigate = useNavigate();\n\n const {getValues} = useFormContext();\n const formPath = `settings.menus.${menuIndex!}` as 'settings.menus.0';\n const menu = getValues(formPath);\n\n useEffect(() => {\n // go to menu list, if menu can't be found\n if (!menu) {\n navigate('/admin/appearance/menus');\n } else {\n appearanceState().preview.setHighlight(`[data-menu-id=\"${menu.id}\"]`);\n }\n }, [navigate, menu]);\n\n if (!menu) {\n return null;\n }\n\n return ;\n}\n\ninterface MenuEditorFormProps {\n formPath: 'settings.menus.0';\n}\nfunction MenuEditorSection({formPath}: MenuEditorFormProps) {\n const {\n site: {has_mobile_app},\n } = useSettings();\n const menuSectionConfig = useAppearanceStore(\n s => s.config?.sections.menus.config,\n ) as MenuSectionConfig;\n\n const menuPositions = useMemo(() => {\n const positions = [...menuSectionConfig?.positions];\n if (has_mobile_app) {\n positions.push('mobile-app-about');\n }\n return positions.map(position => ({\n key: position,\n name: position.replaceAll('-', ' '),\n }));\n }, [menuSectionConfig, has_mobile_app]);\n\n const fieldArray = useFieldArray<\n AppearanceValues,\n `settings.menus.0.items`,\n 'key'\n >({\n name: `${formPath}.items`,\n keyName: 'key',\n });\n\n return (\n \n
\n }\n className=\"mb-20\"\n autoFocus\n />\n }\n description={\n \n }\n >\n {menuPositions.map(item => (\n \n ))}\n \n
\n \n
\n \n
\n
\n );\n}\n\ninterface ItemListProps {\n fieldArray: UseFieldArrayReturn<\n AppearanceValues,\n 'settings.menus.0.items',\n 'key'\n >;\n}\nfunction MenuItemsManager({fieldArray: {append, fields, move}}: ItemListProps) {\n const navigate = useNavigate();\n\n return (\n \n
\n \n {\n if (menuItemConfig) {\n append({...menuItemConfig});\n navigate(`items/${fields.length}`);\n }\n }}\n >\n }\n >\n \n \n \n \n
\n
\n {fields.map((item, index) => (\n {\n move(oldIndex, newIndex);\n }}\n />\n ))}\n {!fields.length ? (\n }\n title={}\n description={\n \n }\n />\n ) : null}\n
\n
\n );\n}\n\nfunction DeleteMenuTrigger() {\n const navigate = useNavigate();\n const {menuIndex} = useParams();\n const {fields, remove} = useFieldArray<\n AppearanceValues,\n 'settings.menus',\n 'key'\n >({\n name: 'settings.menus',\n keyName: 'key',\n });\n if (!menuIndex) return null;\n const menu = fields[+menuIndex];\n\n return (\n {\n if (isConfirmed) {\n const index = fields.findIndex(m => m.id === menu.id);\n remove(index);\n navigate('/admin/appearance/menus');\n }\n }}\n >\n }\n >\n \n \n }\n body={\n \n }\n confirm={}\n />\n \n );\n}\n\ninterface MenuListItemProps {\n item: MenuItemConfig;\n items: FieldArrayWithId[];\n index: number;\n onSortEnd: UseSortableProps['onSortEnd'];\n}\nfunction MenuListItem({item, items, index, onSortEnd}: MenuListItemProps) {\n const ref = useRef(null);\n const {sortableProps, dragHandleRef} = useSortable({\n item,\n items,\n type: 'menuEditorSortable',\n ref,\n onSortEnd,\n strategy: 'liveSort',\n });\n\n const Icon = item.icon && createSvgIconFromTree(item.icon);\n const iconOnlyLabel = (\n
\n {Icon && }\n ()\n
\n );\n\n return (\n \n \n
\n \n \n \n
{item.label || iconOnlyLabel}
\n
\n \n
\n );\n}\n","import {useFieldArray, useFormContext} from 'react-hook-form';\nimport {Fragment, useEffect} from 'react';\nimport {appearanceState, AppearanceValues} from '../../appearance-store';\nimport {Button} from '@common/ui/buttons/button';\nimport {DeleteIcon} from '../../../../icons/material/Delete';\nimport {ConfirmationDialog} from '../../../../ui/overlays/dialog/confirmation-dialog';\nimport {DialogTrigger} from '../../../../ui/overlays/dialog/dialog-trigger';\nimport {Trans} from '../../../../i18n/trans';\nimport {useNavigate} from '../../../../utils/hooks/use-navigate';\nimport {MenuItemForm} from '../../../menus/menu-item-form';\nimport {useParams} from 'react-router-dom';\nimport {MenuItemConfig} from '../../../../core/settings/settings';\n\nexport function MenuItemEditor() {\n const {menuIndex, menuItemIndex} = useParams();\n const navigate = useNavigate();\n\n const {getValues} = useFormContext();\n\n const formPath = `settings.menus.${menuIndex}.items.${menuItemIndex}`;\n const item = getValues(formPath as any);\n\n // go to menu editor, if menu item can't be found\n useEffect(() => {\n if (!item) {\n //navigate(`../`);\n } else {\n appearanceState().preview.setHighlight(\n `[data-menu-item-id=\"${item.id}\"]`\n );\n }\n }, [navigate, item]);\n\n // only render form when menu and item are available to avoid issues with hook form default values\n if (!item || menuItemIndex == null) {\n return null;\n }\n\n return ;\n}\n\ninterface MenuItemEditorSectionProps {\n formPath: string;\n}\nfunction MenuItemEditorSection({formPath}: MenuItemEditorSectionProps) {\n return (\n \n \n
\n \n
\n
\n );\n}\n\nfunction DeleteItemTrigger() {\n const navigate = useNavigate();\n const {menuIndex, menuItemIndex} = useParams();\n const {fields, remove} = useFieldArray({\n name: `settings.menus.${+menuIndex!}.items`,\n });\n\n if (!menuItemIndex) return null;\n\n const item = fields[+menuItemIndex] as MenuItemConfig;\n\n return (\n {\n if (isConfirmed) {\n if (menuItemIndex) {\n remove(+menuItemIndex);\n navigate(`/admin/appearance/menus/${menuIndex}`);\n }\n }\n }}\n >\n }\n >\n \n \n }\n body={\n \n }\n confirm={}\n />\n \n );\n}\n","import {appearanceState, useAppearanceStore} from '../appearance-store';\nimport {FormImageSelector} from '@common/ui/images/image-selector';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {Trans} from '@common/i18n/trans';\nimport {Fragment, ReactNode} from 'react';\nimport {Settings} from '../../../core/settings/settings';\n\nexport function GeneralSection() {\n return (\n \n }\n description={\n \n }\n type=\"favicon\"\n />\n }\n description={}\n type=\"logo_light\"\n />\n }\n description={\n \n }\n type=\"logo_dark\"\n />\n }\n description={\n \n }\n type=\"logo_light_mobile\"\n />\n }\n description={\n \n }\n type=\"logo_dark_mobile\"\n />\n \n \n \n );\n}\n\ninterface ImageSelectorProps {\n label: ReactNode;\n description: ReactNode;\n type: keyof Settings['branding'];\n}\nfunction BrandingImageSelector({label, description, type}: ImageSelectorProps) {\n const defaultValue = useAppearanceStore(\n s => s.defaults?.settings.branding[type]\n );\n return (\n {\n appearanceState().preview.setHighlight('[data-logo=\"navbar\"]');\n }}\n />\n );\n}\nfunction SiteNameTextField() {\n return (\n }\n />\n );\n}\n\nfunction SiteDescriptionTextArea() {\n return (\n }\n />\n );\n}\n","export function randomNumber(min: number = 1, max: number = 10000) {\n const randomBuffer = new Uint32Array(1);\n\n window.crypto.getRandomValues(randomBuffer);\n\n const number = randomBuffer[0] / (0xffffffff + 1);\n\n min = Math.ceil(min);\n max = Math.floor(max);\n return Math.floor(number * (max - min + 1)) + min;\n}\n","import {NavLink, useNavigate} from 'react-router-dom';\nimport {Fragment, useEffect} from 'react';\nimport {appearanceState, AppearanceValues} from '../../appearance-store';\nimport {AppearanceButton} from '../../appearance-button';\nimport {Button} from '../../../../ui/buttons/button';\nimport {AddIcon} from '../../../../icons/material/Add';\nimport {randomNumber} from '../../../../utils/string/random-number';\nimport {Trans} from '../../../../i18n/trans';\nimport {useFieldArray} from 'react-hook-form';\nimport {useTrans} from '../../../../i18n/use-trans';\nimport {message} from '../../../../i18n/message';\nimport {useBootstrapData} from '../../../../core/bootstrap-data/bootstrap-data-context';\n\nexport function ThemeList() {\n const {trans} = useTrans();\n const navigate = useNavigate();\n const {\n data: {themes},\n } = useBootstrapData();\n const {fields, append} = useFieldArray<\n AppearanceValues,\n 'appearance.themes.all',\n 'key'\n >({\n name: 'appearance.themes.all',\n keyName: 'key',\n });\n\n useEffect(() => {\n if (themes.selectedThemeId) {\n appearanceState().preview.setActiveTheme(themes.selectedThemeId);\n }\n }, [themes.selectedThemeId]);\n\n return (\n \n
\n }\n onClick={() => {\n const lightThemeColors =\n appearanceState().defaults?.appearance.themes.light!;\n append({\n id: randomNumber(),\n name: trans(message('New theme')),\n values: lightThemeColors,\n });\n navigate(`${fields.length + 1}`);\n }}\n >\n \n \n
\n {fields.map((field, index) => (\n \n {field.name}\n \n ))}\n
\n );\n}\n","import React, {MutableRefObject, ReactNode, Suspense, useState} from 'react';\nimport {Dialog} from '../ui/overlays/dialog/dialog';\nimport {DialogHeader} from '../ui/overlays/dialog/dialog-header';\nimport {Trans} from '../i18n/trans';\nimport {DialogBody} from '../ui/overlays/dialog/dialog-body';\nimport {ProgressCircle} from '../ui/progress/progress-circle';\nimport {useDialogContext} from '../ui/overlays/dialog/dialog-context';\nimport {DialogFooter} from '../ui/overlays/dialog/dialog-footer';\nimport {Button} from '../ui/buttons/button';\nimport type ReactAce from 'react-ace';\n\nconst AceEditor = React.lazy(() => import('./ace-editor'));\n\ninterface TextEditorSourcecodeDialogProps {\n defaultValue: string;\n mode?: 'css' | 'html' | 'php_laravel_blade';\n title: ReactNode;\n onSave?: (value?: string) => void;\n isSaving?: boolean;\n footerStartAction?: ReactNode;\n beautify?: boolean;\n editorRef?: MutableRefObject;\n}\nexport function AceDialog({\n defaultValue,\n mode = 'html',\n title,\n onSave,\n isSaving,\n footerStartAction,\n beautify,\n editorRef,\n}: TextEditorSourcecodeDialogProps) {\n const [value, setValue] = useState(defaultValue);\n const [isValid, setIsValid] = useState(true);\n\n return (\n \n {title}\n \n \n \n \n }\n >\n setValue(newValue)}\n defaultValue={value || ''}\n onIsValidChange={setIsValid}\n editorRef={editorRef}\n />\n \n \n \n \n );\n}\n\ninterface FooterProps {\n disabled: boolean | undefined;\n value?: string;\n onSave?: (value?: string) => void;\n startAction?: ReactNode;\n}\nfunction Footer({disabled, value, onSave, startAction}: FooterProps) {\n const {close} = useDialogContext();\n return (\n \n \n {\n if (onSave) {\n onSave(value);\n } else {\n close(value);\n }\n }}\n >\n \n \n \n );\n}\n","import {useQuery} from '@tanstack/react-query';\nimport {apiClient} from '@common/http/query-client';\n\nexport function useSeoTags(name: string | string[]) {\n return useQuery({\n queryKey: ['admin', 'seo-tags', name],\n queryFn: () => fetchTags(name),\n });\n}\n\nfunction fetchTags(name: string | string[]) {\n return apiClient\n .get<\n Record<\n string,\n {\n custom: string | null;\n original: string;\n }\n >\n >(`admin/appearance/seo-tags/${name}`)\n .then(response => response.data);\n}\n","import {useMutation, useQueryClient} from '@tanstack/react-query';\nimport {apiClient} from '@common/http/query-client';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';\nimport {toast} from '@common/ui/toast/toast';\nimport {message} from '@common/i18n/message';\n\ninterface Response extends BackendResponse {}\n\nexport function useUpdateSeoTags(name: string) {\n const queryClient = useQueryClient();\n return useMutation({\n mutationFn: (payload: {tags: string}) => updateTags(name, payload.tags),\n onSuccess: async () => {\n await queryClient.invalidateQueries({\n queryKey: ['admin', 'seo-tags', name],\n });\n toast(message('Updated SEO tags'));\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction updateTags(name: string, tags: string): Promise {\n return apiClient\n .put(`admin/appearance/seo-tags/${name}`, {tags})\n .then(r => r.data);\n}\n","import {Fragment, useRef} from 'react';\nimport {Trans} from '@common/i18n/trans';\nimport {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';\nimport {AppearanceButton} from '@common/admin/appearance/appearance-button';\nimport {AceDialog} from '@common/ace-editor/ace-dialog';\nimport mergedAppearanceConfig from '@common/admin/appearance/config/merged-appearance-config';\nimport {SeoSettingsSectionConfig} from '@common/admin/appearance/types/appearance-editor-config';\nimport {MessageDescriptor} from '@common/i18n/message-descriptor';\nimport {useSeoTags} from '@common/admin/appearance/sections/seo/use-seo-tags';\nimport {useUpdateSeoTags} from '@common/admin/appearance/sections/seo/use-update-seo-tags';\nimport {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';\nimport {FullPageLoader} from '@common/ui/progress/full-page-loader';\nimport {Button} from '@common/ui/buttons/button';\nimport type ReactAce from 'react-ace';\n\nconst pages =\n (\n mergedAppearanceConfig.sections['seo-settings']\n .config as SeoSettingsSectionConfig\n )?.pages || [];\n\nconst names = pages.map(page => page.key);\n\nexport function SeoSection() {\n const {isLoading} = useSeoTags(names);\n\n if (isLoading) {\n return ;\n }\n\n return (\n \n {pages.map(page => (\n \n ))}\n \n );\n}\n\ninterface TagEditorTriggerProps {\n label: MessageDescriptor;\n name: string;\n}\nfunction TagEditorTrigger({label, name}: TagEditorTriggerProps) {\n const {data, isLoading} = useSeoTags(names);\n\n return (\n \n \n \n \n {data ? : null}\n \n );\n}\n\ninterface TagsEditorDialogProps {\n name: string;\n value: {custom: string | null; original: string};\n}\nfunction TagsEditorDialog({name, value}: TagsEditorDialogProps) {\n const {close} = useDialogContext();\n const updateTags = useUpdateSeoTags(name);\n const editorRef = useRef(null);\n\n const resetButton = (\n {\n if (editorRef.current) {\n editorRef.current.editor.setValue(value.original);\n }\n }}\n >\n \n \n );\n\n return (\n }\n footerStartAction={resetButton}\n editorRef={editorRef}\n defaultValue={value.custom || value.original}\n isSaving={updateTags.isPending}\n beautify={false}\n onSave={newValue => {\n if (newValue != null) {\n updateTags.mutate(\n {tags: newValue},\n {\n onSuccess: () => close(),\n },\n );\n }\n }}\n />\n );\n}\n","import {AppearanceButton} from '@common/admin/appearance/appearance-button';\nimport {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';\nimport {Trans} from '@common/i18n/trans';\nimport {useFormContext} from 'react-hook-form';\nimport {\n appearanceState,\n AppearanceValues,\n} from '@common/admin/appearance/appearance-store';\nimport {AceDialog} from '@common/ace-editor/ace-dialog';\nimport {Fragment} from 'react';\n\nexport function CustomCodeSection() {\n return (\n \n \n \n \n );\n}\n\ninterface CustomCodeDialogTriggerProps {\n mode: 'html' | 'css';\n}\nfunction CustomCodeDialogTrigger({mode}: CustomCodeDialogTriggerProps) {\n const {getValues} = useFormContext();\n const {setValue} = useFormContext();\n\n const title =\n mode === 'html' ? (\n \n ) : (\n \n );\n\n return (\n {\n if (newValue != null) {\n setValue(`appearance.custom_code.${mode}`, newValue, {\n shouldDirty: true,\n });\n appearanceState().preview.setCustomCode(mode, newValue);\n }\n }}\n >\n {title}\n \n \n );\n}\n","export default \"__VITE_ASSET__8acde003__\"","import {Permission} from './permission';\nimport {Subscription} from '../billing/subscription';\nimport {Role} from './role';\nimport {SocialProfile} from './social-profile';\nimport {AccessToken} from './access-token';\nimport type {ActiveSession} from '@common/auth/ui/account-settings/sessions-panel/requests/use-user-sessions';\n\nexport const USER_MODEL = 'user';\n\nexport interface User {\n id: number;\n display_name: string;\n username?: string;\n first_name?: string;\n last_name?: string;\n avatar?: string;\n email_verified_at: string;\n permissions?: Permission[];\n email: string;\n password: string;\n language: string;\n timezone: string;\n country: string;\n created_at: string;\n updated_at: string;\n subscriptions?: Omit[];\n roles: Role[];\n social_profiles: SocialProfile[];\n tokens?: AccessToken[];\n has_password: boolean;\n available_space: number | null;\n unread_notifications_count?: number;\n card_last_four?: number;\n card_brand?: string;\n card_expires?: string;\n model_type: typeof USER_MODEL;\n banned_at?: string;\n followed_users?: Omit[];\n followers_count?: number;\n followed_users_count?: number;\n followers?: Omit[];\n last_login?: ActiveSession;\n bans?: {\n id: number;\n comment: string;\n expired_at?: string;\n }[];\n two_factor_confirmed_at?: string;\n two_factor_recovery_codes?: string[];\n}\n","import {\n BackendFilter,\n FilterControlType,\n FilterOperator,\n} from '../../datatable/filters/backend-filter';\nimport {message} from '../../i18n/message';\nimport {USER_MODEL} from '../../auth/user';\nimport {SiteConfigContextValue} from '@common/core/settings/site-config-context';\nimport {\n createdAtFilter,\n updatedAtFilter,\n} from '@common/datatable/filters/timestamp-filters';\n\nexport const CustomPageDatatableFilters = (\n config: SiteConfigContextValue\n): BackendFilter[] => {\n const dynamicFilters: BackendFilter[] =\n config.customPages.types.length > 1\n ? [\n {\n control: {\n type: FilterControlType.Select,\n defaultValue: 'default',\n options: config.customPages.types.map(type => ({\n value: type.type,\n label: type.label,\n key: type.type,\n })),\n },\n\n key: 'type',\n label: message('Type'),\n description: message('Type of the page'),\n defaultOperator: FilterOperator.eq,\n },\n ]\n : [];\n\n return [\n {\n key: 'user_id',\n label: message('User'),\n description: message('User page was created by'),\n defaultOperator: FilterOperator.eq,\n control: {\n type: FilterControlType.SelectModel,\n model: USER_MODEL,\n },\n },\n ...dynamicFilters,\n createdAtFilter({\n description: message('Date page was created'),\n }),\n updatedAtFilter({\n description: message('Date page was last updated'),\n }),\n ];\n};\n","import {ColumnConfig} from '@common/datatable/column-config';\nimport {CustomPage} from '@common/admin/custom-pages/custom-page';\nimport {Trans} from '@common/i18n/trans';\nimport {Link} from 'react-router-dom';\nimport {LinkStyle} from '@common/ui/buttons/external-link';\nimport {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';\nimport {FormattedDate} from '@common/i18n/formatted-date';\nimport React from 'react';\nimport {IconButton} from '@common/ui/buttons/icon-button';\nimport {EditIcon} from '@common/icons/material/Edit';\n\nexport const CustomPageDatatableColumns: ColumnConfig[] = [\n {\n key: 'slug',\n allowsSorting: true,\n width: 'flex-2 min-w-200',\n visibleInMode: 'all',\n header: () => ,\n body: page => (\n \n {page.slug}\n \n ),\n },\n {\n key: 'user_id',\n allowsSorting: true,\n width: 'flex-2 min-w-140',\n header: () => ,\n body: page =>\n page.user && (\n \n ),\n },\n {\n key: 'type',\n maxWidth: 'max-w-100',\n header: () => ,\n body: page => ,\n },\n {\n key: 'updated_at',\n allowsSorting: true,\n width: 'w-100',\n header: () => ,\n body: page => ,\n },\n {\n key: 'actions',\n header: () => ,\n hideHeader: true,\n align: 'end',\n width: 'w-84 flex-shrink-0',\n visibleInMode: 'all',\n body: page => (\n \n \n \n ),\n },\n];\n","import React, {useContext, useMemo} from 'react';\nimport {Link} from 'react-router-dom';\nimport {DataTablePage} from '../../datatable/page/data-table-page';\nimport {Trans} from '../../i18n/trans';\nimport {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';\nimport articlesSvg from './articles.svg';\nimport {DataTableAddItemButton} from '../../datatable/data-table-add-item-button';\nimport {CustomPageDatatableFilters} from './custom-page-datatable-filters';\nimport {DeleteSelectedItemsAction} from '../../datatable/page/delete-selected-items-action';\nimport {CustomPageDatatableColumns} from '@common/admin/custom-pages/custom-page-datatable-columns';\nimport {SiteConfigContext} from '@common/core/settings/site-config-context';\n\nexport function CustomPageDatablePage() {\n const config = useContext(SiteConfigContext);\n const filters = useMemo(() => {\n return CustomPageDatatableFilters(config);\n }, [config]);\n\n return (\n }\n filters={filters}\n columns={CustomPageDatatableColumns}\n queryParams={{with: 'user'}}\n actions={}\n selectedActions={}\n emptyStateMessage={\n }\n filteringTitle={}\n />\n }\n />\n );\n}\n\nfunction Actions() {\n return (\n \n \n \n );\n}\n","import {SettingsNavItem} from '@common/admin/settings/settings-nav-config';\nimport {message} from '@common/i18n/message';\n\nexport const AppSettingsNavConfig: SettingsNavItem[] = [\n {label: message('Local search'), to: 'search'},\n {label: message('Content'), to: 'content'},\n {label: message('Videos'), to: 'videos'},\n];\n","import {AppSettingsNavConfig} from '@app/admin/settings/app-settings-nav-config';\nimport {message} from '../../i18n/message';\nimport {MessageDescriptor} from '../../i18n/message-descriptor';\nimport {To} from 'react-router-dom';\nimport {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';\n\nexport interface SettingsNavItem {\n label: MessageDescriptor;\n to: To;\n}\n\nconst filteredSettingsNavConfig: (SettingsNavItem | false)[] = [\n {label: message('General'), to: 'general'},\n ...AppSettingsNavConfig,\n getBootstrapData().settings.billing.integrated && {\n label: message('Subscriptions'),\n to: 'subscriptions',\n },\n {label: message('Localization'), to: 'localization'},\n {\n label: message('Authentication'),\n to: 'authentication',\n },\n {label: message('Uploading'), to: 'uploading'},\n {label: message('Outgoing email'), to: 'outgoing-email'},\n {label: message('Cache'), to: 'cache'},\n {label: message('Analytics'), to: 'analytics'},\n {label: message('Logging'), to: 'logging'},\n {label: message('Queue'), to: 'queue'},\n {label: message('Recaptcha'), to: 'recaptcha'},\n {label: message('GDPR'), to: 'gdpr'},\n {\n label: message('Menus'),\n to: '/admin/appearance/menus',\n },\n {\n label: message('Seo'),\n to: '/admin/appearance/seo-settings',\n },\n {\n label: message('Themes'),\n to: '/admin/appearance/themes',\n },\n].filter(Boolean);\n\nexport const SettingsNavConfig = filteredSettingsNavConfig as SettingsNavItem[];\n","import clsx from 'clsx';\nimport {NavLink, Outlet, useLocation, useNavigate} from 'react-router-dom';\nimport {SettingsNavConfig} from './settings-nav-config';\nimport {useIsMobileMediaQuery} from '../../utils/hooks/is-mobile-media-query';\nimport {Option, Select} from '../../ui/forms/select/select';\nimport {Trans} from '../../i18n/trans';\nimport {StaticPageTitle} from '../../seo/static-page-title';\n\ninterface Props {\n className?: string;\n}\nexport function SettingsLayout({className}: Props) {\n const isMobile = useIsMobileMediaQuery();\n return (\n \n \n \n \n {isMobile ? : }\n
\n \n
\n \n );\n}\n\nfunction MobileNav() {\n const {pathname} = useLocation();\n const navigate = useNavigate();\n const value = pathname.split('/').pop();\n\n return (\n {\n navigate(newPage as string);\n }}\n >\n {SettingsNavConfig.map(item => (\n \n ))}\n \n );\n}\n\nfunction DesktopNav() {\n return (\n
\n {SettingsNavConfig.map(item => (\n \n clsx(\n 'mb-8 block whitespace-nowrap rounded-button p-14 text-sm transition-bg-color',\n isActive\n ? 'bg-primary/6 font-semibold text-primary'\n : 'hover:bg-hover',\n )\n }\n >\n \n \n ))}\n
\n );\n}\n","import {useQuery} from '@tanstack/react-query';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {AdminSettings} from '../admin-settings';\nimport {apiClient} from '@common/http/query-client';\n\nexport interface FetchAdminSettingsResponse\n extends BackendResponse,\n AdminSettings {}\n\nexport function useAdminSettings() {\n return useQuery({\n queryKey: ['fetchAdminSettings'],\n queryFn: () => fetchAdminSettings(),\n // prevent automatic re-fetching so diffing with previous settings work properly\n staleTime: Infinity,\n });\n}\n\nfunction fetchAdminSettings(): Promise {\n return apiClient.get('settings').then(response => response.data);\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {BackendResponse} from '../../http/backend-response/backend-response';\nimport {toast} from '../../ui/toast/toast';\nimport {message} from '../../i18n/message';\nimport {apiClient} from '../../http/query-client';\nimport {showHttpErrorToast} from '../../utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {}\n\nfunction GenerateSitemap(): Promise {\n return apiClient.post('sitemap/generate').then(r => r.data);\n}\n\nexport function useGenerateSitemap() {\n return useMutation({\n mutationFn: () => GenerateSitemap(),\n onSuccess: () => {\n toast(message('Sitemap generated'));\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {UseFormReturn} from 'react-hook-form';\nimport {diff} from 'deep-object-diff';\nimport dot from 'dot-object';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {toast} from '@common/ui/toast/toast';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {AdminSettings} from '@common/admin/settings/admin-settings';\nimport {onFormQueryError} from '@common/errors/on-form-query-error';\nimport {useAdminSettings} from '@common/admin/settings/requests/use-admin-settings';\nimport {message} from '@common/i18n/message';\n\ninterface Response extends BackendResponse {}\n\nexport interface AdminSettingsWithFiles {\n files?: Record;\n client?: Partial;\n server?: Partial;\n}\n\nexport function useUpdateAdminSettings(\n form: UseFormReturn,\n) {\n const {data: original} = useAdminSettings();\n\n return useMutation({\n mutationFn: (props: AdminSettingsWithFiles) => {\n //need to convert these to json, otherwise only single key from object would be sent due to diffing\n if (props.client?.cookie_notice?.button) {\n props.client.cookie_notice.button = JSON.stringify(\n props.client.cookie_notice.button,\n ) as any;\n }\n if (props.client?.registration?.policies) {\n props.client.registration.policies = JSON.stringify(\n props.client.registration.policies,\n ) as any;\n }\n if ((props.client as any)?.artistPage?.tabs) {\n (props.client as any).artistPage.tabs = JSON.stringify(\n (props.client as any).artistPage.tabs,\n ) as any;\n }\n if ((props.client as any)?.title_page?.sections) {\n (props.client as any).title_page.sections = JSON.stringify(\n (props.client as any).title_page.sections,\n ) as any;\n }\n if ((props.client as any)?.incoming_email) {\n (props.client as any).incoming_email = JSON.stringify(\n (props.client as any).incoming_email,\n ) as any;\n }\n if ((props.client as any)?.publish?.default_credentials) {\n (props.client as any).publish.default_credentials = JSON.stringify(\n (props.client as any).publish.default_credentials,\n ) as any;\n }\n\n const client = props.client ? diff(original!.client, props.client) : null;\n const server = props.server ? diff(original!.server, props.server) : null;\n return updateAdminSettings({\n client,\n server,\n files: props.files,\n } as AdminSettings);\n },\n onSuccess: () => {\n toast(message('Settings updated'), {\n position: 'bottom-right',\n });\n queryClient.invalidateQueries({queryKey: ['fetchAdminSettings']});\n },\n onError: r => onFormQueryError(r, form),\n });\n}\n\nfunction updateAdminSettings({\n client,\n server,\n files,\n}: AdminSettingsWithFiles): Promise {\n const formData = new FormData();\n if (client) {\n formData.set('client', JSON.stringify(dot.dot(client)));\n }\n if (server) {\n formData.set('server', JSON.stringify(dot.dot(server)));\n }\n Object.entries(files || {}).forEach(([key, file]) => {\n formData.set(key, file);\n });\n return apiClient\n .post('settings', formData, {\n headers: {\n 'Content-Type': 'multipart/form-data',\n },\n })\n .then(r => r.data);\n}\n","import {FieldErrors, useForm} from 'react-hook-form';\nimport {Fragment, ReactNode} from 'react';\nimport {\n AdminSettingsWithFiles,\n useUpdateAdminSettings,\n} from './requests/update-admin-settings';\nimport {AdminSettings} from './admin-settings';\nimport {useAdminSettings} from './requests/use-admin-settings';\nimport {Form} from '../../ui/forms/form';\nimport {Button} from '../../ui/buttons/button';\nimport {ProgressCircle} from '../../ui/progress/progress-circle';\nimport {ProgressBar} from '../../ui/progress/progress-bar';\nimport {Trans} from '../../i18n/trans';\n\ninterface Props {\n title: ReactNode;\n description: ReactNode;\n children: ReactNode;\n transformValues?: (values: AdminSettingsWithFiles) => AdminSettingsWithFiles;\n}\nexport function SettingsPanel({\n title,\n description,\n children,\n transformValues,\n}: Props) {\n const {data} = useAdminSettings();\n\n return (\n
\n
\n

{title}

\n
{description}
\n
\n {data ? (\n \n {children}\n \n ) : (\n \n )}\n
\n );\n}\n\ninterface FormWrapperProps {\n children: ReactNode;\n defaultValues: AdminSettings;\n transformValues?: (values: AdminSettingsWithFiles) => AdminSettingsWithFiles;\n}\nfunction FormWrapper({\n children,\n defaultValues,\n transformValues,\n}: FormWrapperProps) {\n const form = useForm({defaultValues});\n const updateSettings = useUpdateAdminSettings(form);\n return (\n \n {\n // clear group errors, because hook form won't automatically\n // clear errors that are not bound to a specific form field\n const errors = form.formState.errors as FieldErrors;\n const keys = Object.keys(errors).filter(key => {\n return key.endsWith('_group');\n });\n form.clearErrors(keys as any);\n }}\n onSubmit={value => {\n value = transformValues ? transformValues(value) : value;\n updateSettings.mutate(value);\n }}\n >\n {children}\n
\n \n \n \n
\n \n {updateSettings.isPending && (\n \n )}\n \n );\n}\n","export function SettingsSeparator() {\n return
;\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const LinkIcon = createSvgIcon(\n \n, 'LinkOutlined');\n","import clsx from 'clsx';\nimport {LinkIcon} from '../../icons/material/Link';\nimport {ExternalLink} from '../../ui/buttons/external-link';\nimport {Trans} from '../../i18n/trans';\nimport {useSettings} from '../../core/settings/use-settings';\n\ninterface LearnMoreLinkProps {\n link: string;\n className?: string;\n}\nexport function LearnMoreLink({link, className}: LearnMoreLinkProps) {\n const {site} = useSettings();\n if (site.hide_docs_button) {\n return null;\n }\n return (\n
\n \n \n \n \n
\n );\n}\n","import {useAdminSettings} from '../requests/use-admin-settings';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {FormSelect, Option} from '../../../ui/forms/select/select';\nimport {FormSwitch} from '@common/ui/forms/toggle/switch';\nimport {Button} from '@common/ui/buttons/button';\nimport {useGenerateSitemap} from '../generate-sitemap';\nimport {ExternalLink} from '@common/ui/buttons/external-link';\nimport {SettingsPanel} from '../settings-panel';\nimport {SettingsSeparator} from '../settings-separator';\nimport {LearnMoreLink} from '../learn-more-link';\nimport {Trans} from '@common/i18n/trans';\nimport {Fragment, useContext} from 'react';\nimport {SiteConfigContext} from '@common/core/settings/site-config-context';\nimport {useSettings} from '@common/core/settings/use-settings';\nimport {useBootstrapData} from '@common/core/bootstrap-data/bootstrap-data-context';\nimport {useValueLists} from '@common/http/value-lists';\nimport {useFormContext} from 'react-hook-form';\nimport {AdminSettingsWithFiles} from '@common/admin/settings/requests/update-admin-settings';\n\nexport function GeneralSettings() {\n return (\n }\n description={\n \n }\n >\n \n \n \n \n \n \n \n \n );\n}\n\nfunction SiteUrlSection() {\n const {data} = useAdminSettings();\n\n if (!data) return null;\n\n let append = null;\n const server = data!.server;\n const isInvalid = server.newAppUrl && server.newAppUrl !== server.app_url;\n if (isInvalid) {\n append = (\n
\n {chunks},\n }}\n message=\"Base site url is set as :baseUrl in configuration, but current url is :currentUrl. It is recommended to set the primary url you want to use in configuration file and then redirect all other url versions to this primary version via cpanel or .htaccess file.\"\n />\n
\n );\n }\n\n return (\n \n }\n description={\n \n }\n />\n {append}\n \n );\n}\n\nfunction HomepageSection() {\n const {watch} = useFormContext();\n const {homepage} = useContext(SiteConfigContext);\n const {data} = useValueLists(['menuItemCategories']);\n const selectedType = watch('client.homepage.type');\n\n return (\n
\n }\n description={\n \n }\n >\n {homepage.options.map(option => (\n \n ))}\n {data?.menuItemCategories?.map(category => (\n \n ))}\n \n {data?.menuItemCategories?.map(category => {\n return selectedType === category.type ? (\n \n }\n >\n {category.items.map(item => (\n \n ))}\n \n ) : null;\n })}\n
\n );\n}\n\nfunction ThemeSection() {\n const {\n data: {themes},\n } = useBootstrapData();\n return (\n \n }\n description={\n \n }\n >\n \n {themes.all.map(theme => (\n \n ))}\n \n \n }\n >\n \n \n \n );\n}\n\nfunction SitemapSection() {\n const generateSitemap = useGenerateSitemap();\n const {base_url} = useSettings();\n\n const url = `${base_url}/storage/sitemaps/sitemap-index.xml`;\n const link = {url};\n\n return (\n <>\n {\n generateSitemap.mutate();\n }}\n >\n \n \n
\n \n
\n \n );\n}\n","import {parseColor} from '@react-stately/color';\n\nexport function colorToThemeValue(color: string): string {\n return parseColor(color)\n .toString('rgb')\n .replace('rgb(', '')\n .replace(')', '')\n .replace(/, ?/g, ' ');\n}\n","import {useForm, useFormContext} from 'react-hook-form';\nimport {useEffect} from 'react';\nimport {TuneIcon} from '../../../../icons/material/Tune';\nimport {Button} from '../../../../ui/buttons/button';\nimport {CssTheme} from '../../../../ui/themes/css-theme';\nimport {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';\nimport {FormSwitch} from '../../../../ui/forms/toggle/switch';\nimport {AppearanceValues} from '../../appearance-store';\nimport {DialogTrigger} from '../../../../ui/overlays/dialog/dialog-trigger';\nimport {DialogFooter} from '../../../../ui/overlays/dialog/dialog-footer';\nimport {useDialogContext} from '../../../../ui/overlays/dialog/dialog-context';\nimport {Dialog} from '../../../../ui/overlays/dialog/dialog';\nimport {DialogHeader} from '../../../../ui/overlays/dialog/dialog-header';\nimport {DialogBody} from '../../../../ui/overlays/dialog/dialog-body';\nimport {Trans} from '../../../../i18n/trans';\nimport {Form} from '../../../../ui/forms/form';\nimport {useParams} from 'react-router-dom';\n\nexport function ThemeSettingsDialogTrigger() {\n const {getValues, setValue} = useFormContext();\n const {themeIndex} = useParams();\n const theme = getValues(`appearance.themes.all.${+themeIndex!}`);\n\n return (\n {\n if (!value) return;\n\n getValues('appearance.themes.all').forEach((currentTheme, index) => {\n // update changed theme\n if (currentTheme.id === value.id) {\n setValue(`appearance.themes.all.${index}`, value, {\n shouldDirty: true,\n });\n return;\n }\n\n // unset \"default_light\" and \"default_dark\" on other themes\n if (value.default_light) {\n setValue(\n `appearance.themes.all.${index}`,\n {...currentTheme, default_light: false},\n {shouldDirty: true}\n );\n return;\n }\n if (value.default_dark) {\n setValue(\n `appearance.themes.all.${index}`,\n {...currentTheme, default_dark: false},\n {shouldDirty: true}\n );\n return;\n }\n });\n }}\n >\n }\n >\n \n \n \n \n );\n}\n\ninterface SettingsDialogProps {\n theme: CssTheme;\n}\nfunction SettingsDialog({theme}: SettingsDialogProps) {\n const form = useForm({defaultValues: theme});\n const {close, formId} = useDialogContext();\n\n useEffect(() => {\n const subscription = form.watch((value, {name}) => {\n // theme can only be set as either light or dark default\n if (name === 'default_light' && value.default_light) {\n form.setValue('default_dark', false);\n }\n if (name === 'default_dark' && value.default_dark) {\n form.setValue('default_light', false);\n }\n });\n return () => subscription.unsubscribe();\n }, [form]);\n\n return (\n \n \n \n \n \n {\n close(values);\n }}\n >\n }\n className=\"mb-30\"\n autoFocus\n />\n \n }\n >\n \n \n \n }\n >\n \n \n \n }\n >\n \n \n \n \n \n {\n close();\n }}\n >\n \n \n \n \n \n \n \n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const RestartAltIcon = createSvgIcon(\n \n, 'RestartAltOutlined');\n","import {Fragment, useState} from 'react';\nimport {DeleteIcon} from '../../../../icons/material/Delete';\nimport {ConfirmationDialog} from '../../../../ui/overlays/dialog/confirmation-dialog';\nimport {IconButton} from '../../../../ui/buttons/icon-button';\nimport {MoreVertIcon} from '../../../../icons/material/MoreVert';\nimport {RestartAltIcon} from '../../../../icons/material/RestartAlt';\nimport {appearanceState, AppearanceValues} from '../../appearance-store';\nimport {toast} from '../../../../ui/toast/toast';\nimport {\n Menu,\n MenuItem,\n MenuTrigger,\n} from '../../../../ui/navigation/menu/menu-trigger';\nimport {DialogTrigger} from '../../../../ui/overlays/dialog/dialog-trigger';\nimport {message} from '../../../../i18n/message';\nimport {Trans} from '../../../../i18n/trans';\nimport {useNavigate} from '../../../../utils/hooks/use-navigate';\nimport {useFieldArray, useFormContext} from 'react-hook-form';\nimport {useParams} from 'react-router-dom';\n\nexport function ThemeMoreOptionsButton() {\n const navigate = useNavigate();\n const {themeIndex} = useParams();\n const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);\n const {setValue, getValues} = useFormContext();\n const {fields, remove} = useFieldArray({\n name: 'appearance.themes.all',\n });\n\n const deleteTheme = () => {\n if (fields.length <= 1) {\n toast.danger(message('At least one theme is required'));\n return;\n }\n if (themeIndex) {\n navigate('/admin/appearance/themes');\n remove(+themeIndex);\n setValue('appearance.themes.selectedThemeId', null);\n }\n };\n\n return (\n \n {\n if (key === 'delete') {\n setConfirmDialogOpen(true);\n } else if (key === 'reset') {\n const path =\n `appearance.themes.all.${+themeIndex!}` as 'appearance.themes.all.0';\n const defaultColors = getValues(`${path}.is_dark`)\n ? appearanceState().defaults!.appearance.themes.dark\n : appearanceState().defaults!.appearance.themes.light;\n\n Object.entries(defaultColors).forEach(([colorName, themeValue]) => {\n appearanceState().preview.setThemeValue(colorName, themeValue);\n });\n appearanceState().preview.setThemeFont(null);\n\n setValue(`${path}.values`, defaultColors, {\n shouldDirty: true,\n });\n setValue(`${path}.font`, undefined, {\n shouldDirty: true,\n });\n }\n }}\n >\n \n \n \n \n }>\n \n \n }>\n \n \n \n \n {\n if (isConfirmed) {\n deleteTheme();\n }\n setConfirmDialogOpen(false);\n }}\n >\n }\n body={}\n confirm={}\n />\n \n \n );\n}\n","import {message} from '@common/i18n/message';\nimport {useParams} from 'react-router-dom';\nimport {useFormContext} from 'react-hook-form';\nimport {AppearanceValues} from '@common/admin/appearance/appearance-store';\nimport {Menu, MenuTrigger} from '@common/ui/navigation/menu/menu-trigger';\nimport {AppearanceButton} from '@common/admin/appearance/appearance-button';\nimport {ColorIcon} from '@common/admin/appearance/sections/themes/color-icon';\nimport clsx from 'clsx';\nimport {Trans} from '@common/i18n/trans';\nimport {Item} from '@common/ui/forms/listbox/item';\n\nconst navbarColorMap = [\n {\n label: message('Accent'),\n value: 'primary',\n bgColor: 'bg-primary',\n previewBgColor: 'text-primary',\n },\n {\n label: message('Background'),\n value: 'bg',\n bgColor: 'bg-background',\n previewBgColor: 'text-background',\n },\n {\n label: message('Background alt'),\n value: 'bg-alt',\n bgColor: 'bg-alt',\n previewBgColor: 'text-background-alt',\n },\n {\n label: message('Transparent'),\n value: 'transparent',\n bgColor: 'bg-transparent',\n previewBgColor: 'text-transparent',\n },\n];\n\nexport function NavbarColorPicker() {\n const {themeIndex} = useParams();\n const {watch, setValue} = useFormContext();\n const key =\n `appearance.themes.all.${themeIndex!}.values.--be-navbar-color` as 'appearance.themes.all.1.values.--be-navbar-color';\n const selectedValue = watch(key);\n const previewColor = navbarColorMap.find(({value}) => value === selectedValue)\n ?.previewBgColor;\n return (\n {\n setValue(key, value as string, {shouldDirty: true});\n }}\n >\n \n }\n >\n \n \n \n {navbarColorMap.map(({label, value, bgColor}) => (\n \n }\n >\n \n \n ))}\n \n \n );\n}\n","import {parseColor} from '@react-stately/color';\n\nexport function themeValueToHex(value: string): string {\n try {\n return parseColor(`rgb(${value.split(' ').join(',')})`).toString('hex');\n } catch (e) {\n return value;\n }\n}\n","import {Link, useNavigate, useParams} from 'react-router-dom';\nimport {Fragment, ReactNode, useEffect, useState} from 'react';\nimport {\n appearanceState,\n AppearanceValues,\n} from '@common/admin/appearance/appearance-store';\nimport {AppearanceButton} from '@common/admin/appearance/appearance-button';\nimport {ColorIcon} from '@common/admin/appearance/sections/themes/color-icon';\nimport {CssTheme} from '@common/ui/themes/css-theme';\nimport {colorToThemeValue} from '@common/ui/themes/utils/color-to-theme-value';\nimport {ThemeSettingsDialogTrigger} from '@common/admin/appearance/sections/themes/theme-settings-dialog-trigger';\nimport {ThemeMoreOptionsButton} from '@common/admin/appearance/sections/themes/theme-more-options-button';\nimport {ColorPickerDialog} from '@common/ui/color-picker/color-picker-dialog';\nimport {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';\nimport {useFormContext} from 'react-hook-form';\nimport {Trans} from '@common/i18n/trans';\nimport {NavbarColorPicker} from '@common/admin/appearance/sections/themes/navbar-color-picker';\nimport {message} from '@common/i18n/message';\nimport {themeValueToHex} from '@common/ui/themes/utils/theme-value-to-hex';\n\nconst colorList = [\n {\n label: message('Background'),\n key: '--be-background',\n },\n {\n label: message('Background alt'),\n key: '--be-background-alt',\n },\n {\n label: message('Foreground'),\n key: '--be-foreground-base',\n },\n {\n label: message('Accent light'),\n key: '--be-primary-light',\n },\n {\n label: message('Accent'),\n key: '--be-primary',\n },\n {\n label: message('Accent dark'),\n key: '--be-primary-dark',\n },\n {\n label: message('Text on accent'),\n key: '--be-on-primary',\n },\n {\n label: message('Chip'),\n key: '--be-background-chip',\n },\n];\n\nexport function ThemeEditor() {\n const navigate = useNavigate();\n const {themeIndex} = useParams();\n const {getValues, watch} = useFormContext();\n\n const theme = getValues(`appearance.themes.all.${+themeIndex!}`);\n const selectedFont = watch(\n `appearance.themes.all.${+themeIndex!}.font.family`,\n );\n\n // go to theme list, if theme can't be found\n useEffect(() => {\n if (!theme) {\n navigate('/admin/appearance/themes');\n }\n }, [navigate, theme]);\n\n // select theme in preview on initial render\n useEffect(() => {\n if (theme?.id) {\n appearanceState().preview.setActiveTheme(theme.id);\n }\n }, [theme?.id]);\n\n if (!theme) return null;\n\n return (\n \n
\n \n \n
\n
\n }\n >\n \n \n \n \n \n
\n \n
\n \n {colorList.map(color => (\n }\n initialThemeValue={theme.values[color.key]}\n theme={theme}\n />\n ))}\n
\n
\n );\n}\n\ninterface ColorPickerTriggerProps {\n label: ReactNode;\n theme: CssTheme;\n colorName: string;\n initialThemeValue: string;\n}\nfunction ColorPickerTrigger({\n label,\n theme,\n colorName,\n initialThemeValue,\n}: ColorPickerTriggerProps) {\n const {setValue} = useFormContext();\n const {themeIndex} = useParams();\n const [selectedThemeValue, setSelectedThemeValue] =\n useState(initialThemeValue);\n\n // set color as css variable in preview and on button preview, but not in appearance values\n // this way color change can be canceled when color picker is closed and applied explicitly via apply button\n const selectThemeValue = (themeValue: string) => {\n setSelectedThemeValue(themeValue);\n appearanceState().preview.setThemeValue(colorName, themeValue);\n };\n\n useEffect(() => {\n // need to update the color here so changes via \"reset colors\" button are reflected\n setSelectedThemeValue(initialThemeValue);\n }, [initialThemeValue]);\n\n return (\n {\n selectThemeValue(colorToThemeValue(newColor));\n }}\n onClose={(newColor, {valueChanged, initialValue}) => {\n if (newColor && valueChanged) {\n setValue(\n `appearance.themes.all.${+themeIndex!}.values.${colorName}`,\n colorToThemeValue(newColor),\n {shouldDirty: true},\n );\n setValue('appearance.themes.selectedThemeId', theme.id);\n } else {\n // reset to initial value, if apply button was not clicked\n selectThemeValue(initialValue);\n }\n }}\n >\n \n }\n >\n {label}\n \n \n \n );\n}\n","import {useController} from 'react-hook-form';\nimport React, {useMemo} from 'react';\nimport {mergeProps} from '@react-aria/utils';\nimport {\n ChipField,\n ChipValue,\n} from '../../ui/forms/input-field/chip-field/chip-field';\nimport {FormChipFieldProps} from '../../ui/forms/input-field/chip-field/form-chip-field';\n\nexport function JsonChipField({children, ...props}: FormChipFieldProps) {\n const {\n field: {onChange, onBlur, value = [], ref},\n fieldState: {invalid, error},\n } = useController({\n name: props.name,\n });\n\n const arrayValue = useMemo(() => {\n const mixedValue = value as string | string[];\n return typeof mixedValue === 'string' ? JSON.parse(mixedValue) : mixedValue;\n }, [value]);\n\n const formProps: Partial> = {\n onChange: newValue => {\n const jsonValue = JSON.stringify(newValue.map(chip => chip.name));\n onChange(jsonValue);\n },\n onBlur,\n value: arrayValue,\n invalid,\n errorMessage: error?.message,\n };\n\n return ;\n}\n","import {Trans} from '@common/i18n/trans';\nimport {FormSelect} from '@common/ui/forms/select/select';\nimport {Item} from '@common/ui/forms/listbox/item';\nimport {SettingsPanel} from '@common/admin/settings/settings-panel';\nimport {FormSwitch} from '@common/ui/forms/toggle/switch';\nimport {JsonChipField} from '@common/admin/settings/json-chip-field';\nimport {useTrans} from '@common/i18n/use-trans';\n\nexport function VideoSettings() {\n const {trans} = useTrans();\n return (\n }\n description={\n \n }\n >\n \n \n \n }\n >\n \n \n \n }\n >\n \n \n \n }\n >\n \n \n }\n name=\"client.streaming.qualities\"\n placeholder={trans({message: 'Add another...'})}\n />\n \n );\n}\n\nfunction SortingMethodSelect() {\n return (\n }\n selectionMode=\"single\"\n description={\n \n }\n >\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n );\n}\n\nfunction ShownVideoTypeSelect() {\n return (\n }\n selectionMode=\"single\"\n description={\n \n }\n >\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n );\n}\n","import React, {\n Children,\n cloneElement,\n ComponentPropsWithoutRef,\n isValidElement,\n ReactElement,\n ReactNode,\n useContext,\n useRef,\n useState,\n} from 'react';\nimport clsx from 'clsx';\nimport {useLayoutEffect} from '@react-aria/utils';\nimport {getFocusableTreeWalker} from '@react-aria/focus';\nimport {TabContext} from './tabs-context';\n\nexport interface TabPanelsProps {\n children: ReactNode;\n className?: string;\n}\nexport function TabPanels({children, className}: TabPanelsProps) {\n const {selectedTab, isLazy} = useContext(TabContext);\n\n // filter out falsy values, in case of conditional rendering\n const panelArray = Children.toArray(children).filter(p => !!p);\n\n let rendered: ReactNode;\n if (isLazy) {\n const el = panelArray[selectedTab] as ReactElement;\n rendered = isValidElement(el)\n ? cloneElement(panelArray[selectedTab] as ReactElement, {\n index: selectedTab,\n })\n : null;\n } else {\n rendered = panelArray.map((panel, index) => {\n if (isValidElement(panel)) {\n const isSelected = index === selectedTab;\n return cloneElement(panel, {\n index,\n 'aria-hidden': !isSelected,\n className: !isSelected\n ? clsx(panel.props.className, 'hidden')\n : panel.props.className,\n });\n }\n return null;\n });\n }\n\n return
{rendered}
;\n}\n\ninterface TabPanelProps extends ComponentPropsWithoutRef<'div'> {\n className?: string;\n children: ReactNode;\n index?: number;\n}\nexport function TabPanel({\n className,\n children,\n index,\n ...domProps\n}: TabPanelProps) {\n const {id} = useContext(TabContext);\n\n const [tabIndex, setTabIndex] = useState(0);\n const ref = useRef(null);\n\n // The tabpanel should have tabIndex=0 when there are no tabbable elements within it.\n // Otherwise, tabbing from the focused tab should go directly to the first tabbable element\n // within the tabpanel.\n useLayoutEffect(() => {\n if (ref?.current) {\n const update = () => {\n // Detect if there are any tabbable elements and update the tabIndex accordingly.\n const walker = getFocusableTreeWalker(ref.current!, {tabbable: true});\n setTabIndex(walker.nextNode() ? undefined : 0);\n };\n\n update();\n\n // Update when new elements are inserted, or the tabIndex/disabled attribute updates.\n const observer = new MutationObserver(update);\n observer.observe(ref.current, {\n subtree: true,\n childList: true,\n attributes: true,\n attributeFilter: ['tabIndex', 'disabled'],\n });\n\n return () => {\n observer.disconnect();\n };\n }\n }, [ref]);\n\n return (\n \n {children}\n
\n );\n}\n","import {useFormContext} from 'react-hook-form';\nimport {AdminSettings} from '@common/admin/settings/admin-settings';\nimport {Fragment} from 'react';\nimport {FormSwitch} from '@common/ui/forms/toggle/switch';\nimport {Trans} from '@common/i18n/trans';\nimport {FormSelect} from '@common/ui/forms/select/select';\nimport {Item} from '@common/ui/forms/listbox/item';\n\nexport function ContentSettingsGeneralPanel() {\n const {watch} = useFormContext();\n return (\n \n \n \n }\n >\n \n \n \n }\n >\n \n \n {watch('client.titles.enable_comments') && (\n \n }\n >\n \n \n )}\n \n );\n}\n\nfunction SortingMethodSelect() {\n return (\n }\n selectionMode=\"single\"\n description={\n \n }\n >\n \n \n \n \n \n \n \n );\n}\n","import {ReactNode, useEffect, useRef} from 'react';\nimport {useFormContext} from 'react-hook-form';\nimport clsx from 'clsx';\n\ninterface Props {\n children: (isInvalid: boolean) => ReactNode;\n name: string;\n separatorBottom?: boolean;\n separatorTop?: boolean;\n}\nexport function SettingsErrorGroup({\n children,\n name,\n separatorBottom = true,\n separatorTop = true,\n}: Props) {\n const {\n formState: {errors},\n } = useFormContext>();\n\n const ref = useRef(null);\n const error = errors[name];\n\n useEffect(() => {\n if (error) {\n ref.current?.scrollIntoView({behavior: 'smooth'});\n }\n }, [error]);\n\n return (\n \n {children(!!error)}\n {error && (\n \n )}\n \n );\n}\n","import {Fragment} from 'react';\nimport {FormSwitch} from '@common/ui/forms/toggle/switch';\nimport {Trans} from '@common/i18n/trans';\nimport {FormSelect} from '@common/ui/forms/select/select';\nimport {Item} from '@common/ui/forms/listbox/item';\nimport {SettingsSeparator} from '@common/admin/settings/settings-separator';\nimport {useFormContext} from 'react-hook-form';\nimport {AdminSettings} from '@common/admin/settings/admin-settings';\nimport {SettingsErrorGroup} from '@common/admin/settings/settings-error-group';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {useValueLists} from '@common/http/value-lists';\n\nexport function ContentSettingsAutomationPanel() {\n const {watch} = useFormContext();\n return (\n \n \n \n }\n >\n \n \n \n }\n >\n \n \n \n \n }\n >\n \n \n {watch('client.content.people_provider') === 'tmdb' && (\n \n }\n >\n \n \n )}\n \n \n );\n}\n\nfunction SearchMethodSelect() {\n return (\n }\n description={\n \n }\n >\n \n }\n >\n \n \n \n }\n >\n \n \n \n }\n >\n \n \n \n );\n}\n\nfunction TmdbFields() {\n const {data} = useValueLists(['tmdbLanguages']);\n const {watch: w} = useFormContext();\n const shouldShow = [\n w('client.content.people_provider'),\n w('client.content.title_provider'),\n w('client.content.search_provider'),\n ].some(provider => `${provider}`.toLowerCase().includes('tmdb'));\n\n if (!shouldShow) {\n return null;\n }\n\n return (\n \n {isInvalid => (\n \n }\n className=\"mb-24\"\n required\n />\n }\n description={\n \n }\n >\n {data?.tmdbLanguages.map(({code, name}) => (\n \n {name}\n \n ))}\n \n \n \n \n \n )}\n \n );\n}\n","import {Trans} from '@common/i18n/trans';\nimport React, {Fragment, ReactNode, useRef, useState} from 'react';\nimport {DragPreviewRenderer} from '@common/ui/interactions/dnd/use-draggable';\nimport {useFormContext} from 'react-hook-form';\nimport {AdminSettingsWithFiles} from '@common/admin/settings/requests/update-admin-settings';\nimport {moveItemInNewArray} from '@common/utils/array/move-item-in-new-array';\nimport {IconButton} from '@common/ui/buttons/icon-button';\nimport {DragHandleIcon} from '@common/icons/material/DragHandle';\nimport {Checkbox} from '@common/ui/forms/toggle/checkbox';\nimport {DragPreview} from '@common/ui/interactions/dnd/drag-preview';\nimport clsx from 'clsx';\nimport {TitlePageSections} from '@app/titles/pages/title-page/sections/title-page-sections';\nimport {MessageDescriptor} from '@common/i18n/message-descriptor';\nimport {AdminSettings} from '@common/admin/settings/admin-settings';\nimport {useSortable} from '@common/ui/interactions/dnd/sortable/use-sortable';\n\ninterface SectionItem {\n name: (typeof TitlePageSections)[number];\n title: MessageDescriptor;\n}\n\nconst defaultItems: SectionItem[] = [\n {name: 'episodes', title: {message: 'Episode grid'}},\n {name: 'seasons', title: {message: 'Season grid'}},\n {name: 'videos', title: {message: 'Video grid'}},\n {name: 'images', title: {message: 'Image grid'}},\n {name: 'reviews', title: {message: 'Reviews'}},\n {name: 'cast', title: {message: 'Cast grid'}},\n {name: 'related', title: {message: 'Related titles'}},\n];\n\nexport function ContentSettingsTitlePagePanel() {\n const {getValues, setValue} = useFormContext();\n const getSavedValue = (): string[] => {\n return getValues('client.title_page.sections') || [];\n };\n\n const [items, setItems] = useState(() => {\n const savedValue = getSavedValue();\n const sortFn = (x: string) =>\n savedValue.includes(x) ? savedValue.indexOf(x) : savedValue.length;\n return [...defaultItems].sort((a, b) => sortFn(a.name) - sortFn(b.name));\n });\n\n return (\n
\n
\n \n
\n \n
\n
\n {items.map((section, index) => (\n }\n onToggle={(section, checked) => {\n const savedValue = getSavedValue();\n const newValue = checked\n ? [...savedValue, section.name]\n : savedValue.filter(x => x !== section.name);\n setValue('client.title_page.sections', newValue as any);\n }}\n onSortEnd={(oldIndex, newIndex) => {\n const sortedItems = moveItemInNewArray(items, oldIndex, newIndex);\n setItems(sortedItems);\n const savedValue = getSavedValue();\n const newValue = sortedItems\n .filter(x => savedValue.includes(x.name))\n .map(x => x.name);\n setValue('client.title_page.sections', newValue);\n }}\n />\n ))}\n
\n );\n}\n\ninterface ListItemLayoutProps {\n isFirst: boolean;\n items: SectionItem[];\n section: SectionItem;\n title: ReactNode;\n onSortEnd: (oldIndex: number, newIndex: number) => void;\n onToggle: (section: SectionItem, checked: boolean) => void;\n}\nfunction ListItemLayout({\n isFirst,\n title,\n items,\n section,\n onSortEnd,\n onToggle,\n}: ListItemLayoutProps) {\n const ref = useRef(null);\n const previewRef = useRef(null);\n const {watch} = useFormContext();\n\n const savedValue = watch('client.title_page.sections') || [];\n const isChecked = savedValue.includes(section.name);\n\n const {sortableProps, dragHandleRef} = useSortable({\n ref,\n item: section,\n items,\n type: 'titlePageSections',\n preview: previewRef,\n strategy: 'line',\n onSortEnd,\n });\n\n return (\n \n \n \n \n \n
\n
{title}
\n
\n {\n onToggle(section, !isChecked);\n }}\n />\n \n \n
\n );\n}\n\ninterface DragPreviewProps {\n title: ReactNode;\n}\nconst TabDragPreview = React.forwardRef(\n ({title}, ref) => {\n return (\n \n {() => (\n
{title}
\n )}\n
\n );\n },\n);\n","import {Trans} from '@common/i18n/trans';\nimport {SettingsPanel} from '@common/admin/settings/settings-panel';\nimport {Tabs} from '@common/ui/tabs/tabs';\nimport {TabList} from '@common/ui/tabs/tab-list';\nimport {Tab} from '@common/ui/tabs/tab';\nimport {TabPanel, TabPanels} from '@common/ui/tabs/tab-panels';\nimport {ContentSettingsGeneralPanel} from '@app/admin/settings/content-settings/content-settings-general-panel';\nimport {ContentSettingsAutomationPanel} from '@app/admin/settings/content-settings/content-settings-automation-panel';\nimport {ContentSettingsTitlePagePanel} from '@app/admin/settings/content-settings/content-settings-title-page-panel';\n\nexport function ContentSettings() {\n return (\n }\n description={\n \n }\n >\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n );\n}\n","import {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {apiClient} from '@common/http/query-client';\nimport {useQuery} from '@tanstack/react-query';\n\ninterface Response extends BackendResponse {\n models: {model: string; name: string}[];\n}\n\nexport function useSearchModels() {\n return useQuery({\n queryKey: ['search-models'],\n queryFn: () => fetchModels(),\n });\n}\n\nfunction fetchModels(): Promise {\n return apiClient.get('admin/search/models').then(response => response.data);\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient} from '@common/http/query-client';\nimport {toast} from '@common/ui/toast/toast';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {message} from '@common/i18n/message';\nimport {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';\n\ninterface Payload {\n model: string;\n driver: string;\n}\n\nexport function useImportSearchModels() {\n const {trans} = useTrans();\n return useMutation({\n mutationFn: (payload: Payload) => importModels(payload),\n onSuccess: () => {\n toast(trans(message('Imported search models')));\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction importModels(payload: Payload): Promise {\n return apiClient.post('admin/search/import', payload).then(r => r.data);\n}\n","import {FormSelect, Select} from '@common/ui/forms/select/select';\nimport {SettingsPanel} from '../../settings-panel';\nimport {Trans} from '@common/i18n/trans';\nimport {useFormContext} from 'react-hook-form';\nimport {AdminSettingsWithFiles} from '@common/admin/settings/requests/update-admin-settings';\nimport {Item} from '@common/ui/forms/listbox/item';\nimport {SectionHelper} from '@common/ui/section-helper';\nimport {SettingsErrorGroup} from '@common/admin/settings/settings-error-group';\nimport {Fragment, useState} from 'react';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {useSearchModels} from '@common/admin/settings/pages/search-settings/requests/use-search-models';\nimport {Button} from '@common/ui/buttons/button';\nimport {useImportSearchModels} from '@common/admin/settings/pages/search-settings/requests/use-import-search-models';\n\nexport function SearchSettings() {\n return (\n }\n description={\n \n }\n >\n \n \n \n );\n}\n\nfunction SearchMethodSelect() {\n const {watch} = useFormContext();\n const selectedMethod = watch('server.scout_driver');\n\n return (\n \n {isInvalid => (\n \n }\n description={\n \n }\n >\n Mysql\n Meilisearch\n TNTSearch\n \n Elasticsearch\n \n Algolia\n \n {selectedMethod === 'mysql' && }\n {selectedMethod === 'meilisearch' && }\n {selectedMethod === 'algolia' && }\n {selectedMethod ===\n 'Matchish\\\\ScoutElasticSearch\\\\Engines\\\\ElasticSearchEngine' && (\n \n )}\n \n )}\n \n );\n}\n\nfunction MysqlFields() {\n const {clearErrors} = useFormContext();\n return (\n }\n onSelectionChange={() => {\n clearErrors();\n }}\n >\n \n \n \n \n \n \n \n \n \n \n );\n}\n\nfunction MeilisearchFields() {\n return (\n }\n description={\n Meilisearch needs to be installed and running for this method to work.\"\n values={{\n a: parts => (\n \n {parts}\n \n ),\n }}\n />\n }\n />\n );\n}\n\nfunction ElasticsearchField() {\n return (\n }\n description={\n Elasticsearch needs to be installed and running for this method to work.\"\n values={{\n a: parts => (\n \n {parts}\n \n ),\n }}\n />\n }\n />\n );\n}\n\nfunction AlgoliaFields() {\n return (\n \n }\n required\n />\n }\n required\n />\n \n );\n}\n\nfunction ImportRecordsPanel() {\n const {getValues} = useFormContext();\n const {data} = useSearchModels();\n const importModels = useImportSearchModels();\n const [selectedModel, setSelectedModel] = useState('*');\n return (\n }\n description={\n \n \n
\n
\n \n
\n }\n actions={\n
\n }\n selectedValue={selectedModel}\n onSelectionChange={newValue => {\n setSelectedModel(newValue as string);\n }}\n >\n \n \n \n {data?.models.map(item => (\n \n \n \n ))}\n \n {\n importModels.mutate({\n model: selectedModel,\n driver: getValues('server.scout_driver')!,\n });\n }}\n >\n \n \n
\n }\n />\n );\n}\n","import {RouteObject} from 'react-router-dom';\nimport {VideoSettings} from '@app/admin/settings/video-settings';\nimport {ContentSettings} from '@app/admin/settings/content-settings/content-settings';\nimport {SearchSettings} from '@common/admin/settings/pages/search-settings/search-settings';\n\nexport const AppSettingsRoutes: RouteObject[] = [\n {\n path: 'search',\n element: ,\n },\n {\n path: 'videos',\n element: ,\n },\n {\n path: 'content',\n element: ,\n },\n];\n","import {useFormContext} from 'react-hook-form';\nimport {SettingsPanel} from '../settings-panel';\nimport {FormSwitch} from '../../../ui/forms/toggle/switch';\nimport {SettingsSeparator} from '../settings-separator';\nimport {LearnMoreLink} from '../learn-more-link';\nimport {AdminSettings} from '../admin-settings';\nimport {FormTextField} from '../../../ui/forms/input-field/text-field/text-field';\nimport {SettingsErrorGroup} from '../settings-error-group';\nimport {JsonChipField} from '../json-chip-field';\nimport {Tabs} from '../../../ui/tabs/tabs';\nimport {TabList} from '../../../ui/tabs/tab-list';\nimport {Tab} from '../../../ui/tabs/tab';\nimport {TabPanel, TabPanels} from '../../../ui/tabs/tab-panels';\nimport {Trans} from '../../../i18n/trans';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {Fragment} from 'react';\n\nexport function SubscriptionSettings() {\n const {trans} = useTrans();\n return (\n }\n description={\n \n }\n >\n \n \n \n \n \n \n \n \n \n \n \n \n }\n >\n \n \n \n \n \n \n }\n name=\"client.billing.accepted_cards\"\n placeholder={trans({message: 'Add new card...'})}\n />\n \n \n }\n name=\"client.billing.invoice.address\"\n className=\"mb-30\"\n />\n }\n description={\n \n }\n name=\"client.billing.invoice.notes\"\n />\n \n \n \n \n );\n}\n\nfunction PaypalSection() {\n const {watch} = useFormContext();\n const paypalIsEnabled = watch('client.billing.paypal.enable');\n return (\n
\n \n \n \n
\n }\n >\n \n \n {paypalIsEnabled ? (\n \n {isInvalid => (\n \n }\n required\n invalid={isInvalid}\n className=\"mb-20\"\n />\n }\n required\n invalid={isInvalid}\n className=\"mb-20\"\n />\n }\n required\n invalid={isInvalid}\n className=\"mb-20\"\n />\n \n \n \n }\n >\n \n \n \n )}\n \n ) : null}\n \n );\n}\n\nfunction StripeSection() {\n const {watch} = useFormContext();\n const stripeEnabled = watch('client.billing.stripe.enable');\n return (\n \n \n \n \n \n }\n >\n \n \n {stripeEnabled ? (\n \n {isInvalid => (\n \n }\n required\n className=\"mb-20\"\n invalid={isInvalid}\n />\n }\n required\n className=\"mb-20\"\n invalid={isInvalid}\n />\n }\n className=\"mb-20\"\n invalid={isInvalid}\n />\n \n )}\n \n ) : null}\n \n );\n}\n","import {FormSelect, Option} from '../../../ui/forms/select/select';\nimport {SettingsPanel} from '../settings-panel';\nimport {useValueLists} from '../../../http/value-lists';\nimport {Section} from '../../../ui/forms/listbox/section';\nimport {FormRadio} from '../../../ui/forms/radio-group/radio';\nimport {FormRadioGroup} from '../../../ui/forms/radio-group/radio-group';\nimport {DateFormatPresets, FormattedDate} from '../../../i18n/formatted-date';\nimport {FormSwitch} from '../../../ui/forms/toggle/switch';\nimport {Trans} from '../../../i18n/trans';\nimport {useCurrentDateTime} from '../../../i18n/use-current-date-time';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {message} from '@common/i18n/message';\n\nexport function LocalizationSettings() {\n const {data} = useValueLists(['timezones', 'localizations']);\n const today = useCurrentDateTime();\n const {trans} = useTrans();\n return (\n }\n description={\n \n }\n >\n }\n searchPlaceholder={trans(message('Search timezones'))}\n description={\n \n }\n >\n \n {Object.entries(data?.timezones || {}).map(([groupName, timezones]) => (\n
\n {timezones.map(timezone => (\n \n ))}\n
\n ))}\n \n }\n description={\n \n }\n >\n \n {(data?.localizations || []).map(locale => (\n \n ))}\n \n }\n description={\n \n }\n >\n \n \n \n {Object.entries(DateFormatPresets).map(([format, options]) => (\n \n \n \n ))}\n \n \n }\n >\n \n \n \n );\n}\n","import {useFormContext} from 'react-hook-form';\nimport {SettingsPanel} from '@common/admin/settings/settings-panel';\nimport {FormSwitch} from '@common/ui/forms/toggle/switch';\nimport {AdminSettings} from '@common/admin/settings/admin-settings';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {SettingsErrorGroup} from '@common/admin/settings/settings-error-group';\nimport {Trans} from '@common/i18n/trans';\nimport {Fragment} from 'react';\nimport {Link} from 'react-router-dom';\nimport {useSettings} from '@common/core/settings/use-settings';\nimport {SettingsSeparator} from '@common/admin/settings/settings-separator';\nimport {Button} from '@common/ui/buttons/button';\n\nexport function AuthenticationSettings() {\n return (\n }\n description={\n \n }\n >\n \n \n }\n >\n \n \n \n }\n >\n \n \n \n }\n >\n \n \n \n \n \n \n \n }\n description={\n \n }\n />\n \n );\n}\n\nexport function MailNotSetupWarning() {\n const {watch} = useFormContext();\n const mailSetup = watch('server.mail_setup');\n if (mailSetup) return null;\n\n return (\n

\n Fix now\"\n values={{\n a: text => (\n \n {text}\n \n ),\n }}\n />\n

\n );\n}\n\nfunction EmailConfirmationSection() {\n return (\n \n \n \n \n }\n >\n \n \n );\n}\n\nfunction EnvatoSection() {\n const {watch} = useFormContext();\n const settings = useSettings();\n const envatoLoginEnabled = watch('client.social.envato.enable');\n\n if (!(settings as any).envato?.enable) return null;\n\n return (\n \n {isInvalid => (\n <>\n \n }\n >\n \n \n {!!envatoLoginEnabled && (\n <>\n }\n required\n />\n }\n required\n />\n }\n required\n />\n \n )}\n \n )}\n \n );\n}\n\nfunction GoogleSection() {\n const {watch} = useFormContext();\n const googleLoginEnabled = watch('client.social.google.enable');\n\n return (\n \n {isInvalid => (\n <>\n \n }\n >\n \n \n {!!googleLoginEnabled && (\n <>\n }\n required\n />\n }\n required\n />\n \n )}\n \n )}\n \n );\n}\n\nfunction FacebookSection() {\n const {watch} = useFormContext();\n const facebookLoginEnabled = watch('client.social.facebook.enable');\n\n return (\n \n {isInvalid => (\n <>\n \n }\n >\n \n \n {!!facebookLoginEnabled && (\n <>\n }\n required\n />\n }\n required\n />\n \n )}\n \n )}\n \n );\n}\n\nfunction TwitterSection() {\n const {watch} = useFormContext();\n const twitterLoginEnabled = watch('client.social.twitter.enable');\n\n return (\n \n {isInvalid => (\n <>\n \n }\n >\n \n \n {!!twitterLoginEnabled && (\n <>\n }\n required\n />\n }\n required\n />\n \n )}\n \n )}\n \n );\n}\n","import {useQuery} from '@tanstack/react-query';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {apiClient} from '@common/http/query-client';\n\nexport interface FetchMaxServerUploadSizeResponse extends BackendResponse {\n maxSize: string;\n}\n\nfunction fetchMaxServerUploadSize(): Promise {\n return apiClient\n .get('uploads/server-max-file-size')\n .then(response => response.data);\n}\n\nexport function useMaxServerUploadSize() {\n return useQuery({\n queryKey: ['MaxServerUploadSize'],\n queryFn: () => fetchMaxServerUploadSize(),\n });\n}\n","export const spaceUnits = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];\n","export type SpaceUnit = 'KB' | 'MB' | 'GB' | 'TB' | 'PB';\n\nexport function convertToBytes(value: number, unit: SpaceUnit): number {\n if (value == null) return 0;\n switch (unit) {\n case 'KB':\n return value * 1024;\n case 'MB':\n return value * 1024 ** 2;\n case 'GB':\n return value * 1024 ** 3;\n case 'TB':\n return value * 1024 ** 4;\n case 'PB':\n return value * 1024 ** 5;\n default:\n return value;\n }\n}\n","import {useController} from 'react-hook-form';\nimport {mergeProps} from '@react-aria/utils';\nimport React, {useEffect, useState} from 'react';\nimport memoize from 'nano-memoize';\nimport {\n FormTextFieldProps,\n TextField,\n TextFieldProps,\n} from './text-field/text-field';\nimport {prettyBytes} from '../../../uploads/utils/pretty-bytes';\nimport {Option, Select} from '../select/select';\nimport {spaceUnits} from '../../../uploads/utils/space-units';\nimport {\n convertToBytes,\n SpaceUnit,\n} from '../../../uploads/utils/convert-to-bytes';\n\n// 99TB\nconst MaxValue = 108851651149824;\n\nexport const FormFileSizeField = React.forwardRef<\n HTMLDivElement,\n FormTextFieldProps\n>(({name, ...props}, ref) => {\n const {\n field: {\n onChange: setByteValue,\n onBlur,\n value: byteValue = '',\n ref: inputRef,\n },\n fieldState: {invalid, error},\n } = useController({\n name,\n });\n\n const [liveValue, setLiveValue] = useState('');\n const [unit, setUnit] = useState('MB');\n\n useEffect(() => {\n if (byteValue == null || byteValue === '') {\n setLiveValue('');\n return;\n }\n const {amount, unit: newUnit} = fromBytes({\n bytes: Math.min(byteValue, MaxValue),\n });\n setUnit(newUnit || 'MB');\n setLiveValue(Number.isNaN(amount) ? '' : amount);\n }, [byteValue, unit]);\n\n const formProps: TextFieldProps = {\n onChange: e => {\n const value = parseInt(e.target.value);\n if (Number.isNaN(value)) {\n setByteValue(value);\n } else {\n const newBytes = convertToBytes(\n parseInt(e.target.value),\n unit as SpaceUnit\n );\n setByteValue(newBytes);\n }\n },\n onBlur,\n value: liveValue,\n invalid,\n errorMessage: error?.message,\n inputRef,\n };\n\n const unitSelect = (\n {\n const newBytes = convertToBytes(\n (liveValue || 0) as number,\n newUnit as SpaceUnit\n );\n setByteValue(newBytes);\n }}\n >\n {spaceUnits.slice(0, 5).map(u => (\n \n ))}\n \n );\n\n return (\n \n );\n});\n\nconst fromBytes = memoize(\n ({bytes}: {bytes: number}): {amount: number | string; unit: SpaceUnit} => {\n const pretty = prettyBytes(bytes);\n if (!pretty) return {amount: '', unit: 'MB'};\n let amount = parseInt(pretty.split(' ')[0]);\n // get rid of any punctuation\n amount = Math.round(amount);\n return {amount, unit: pretty.split(' ')[1] as SpaceUnit};\n }\n);\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient} from '../../../../http/query-client';\nimport {useTrans} from '../../../../i18n/use-trans';\nimport {BackendResponse} from '../../../../http/backend-response/backend-response';\nimport {showHttpErrorToast} from '../../../../utils/http/show-http-error-toast';\nimport {message} from '../../../../i18n/message';\nimport {toast} from '../../../../ui/toast/toast';\n\ninterface Response extends BackendResponse {}\n\nexport function useUploadS3Cors() {\n const {trans} = useTrans();\n return useMutation({\n mutationFn: () => uploadCors(),\n onSuccess: () => {\n toast(trans(message('CORS file updated')));\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction uploadCors(): Promise {\n return apiClient.post('s3/cors/upload').then(r => r.data);\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient} from '../../../../../http/query-client';\nimport {BackendResponse} from '../../../../../http/backend-response/backend-response';\nimport {showHttpErrorToast} from '../../../../../utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {\n refreshToken: string;\n}\n\ninterface Payload {\n app_key: string;\n app_secret: string;\n access_code: string;\n}\n\nexport function useGenerateDropboxRefreshToken() {\n return useMutation({\n mutationFn: (props: Payload) => generateToken(props),\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction generateToken(payload: Payload): Promise {\n return apiClient\n .post('settings/uploading/dropbox-refresh-token', payload)\n .then(r => r.data);\n}\n","import {Fragment} from 'react';\nimport {FormTextField} from '../../../../../ui/forms/input-field/text-field/text-field';\nimport {Trans} from '../../../../../i18n/trans';\nimport {CredentialFormProps} from '../uploading-settings';\nimport {Button} from '../../../../../ui/buttons/button';\nimport {Dialog} from '../../../../../ui/overlays/dialog/dialog';\nimport {DialogHeader} from '../../../../../ui/overlays/dialog/dialog-header';\nimport {DialogBody} from '../../../../../ui/overlays/dialog/dialog-body';\nimport {useForm, useFormContext} from 'react-hook-form';\nimport {Form} from '../../../../../ui/forms/form';\nimport {DialogTrigger} from '../../../../../ui/overlays/dialog/dialog-trigger';\nimport {AdminSettings} from '../../../admin-settings';\nimport {DialogFooter} from '../../../../../ui/overlays/dialog/dialog-footer';\nimport {useDialogContext} from '../../../../../ui/overlays/dialog/dialog-context';\nimport {useGenerateDropboxRefreshToken} from './use-generate-dropbox-refresh-token';\n\nexport function DropboxForm({isInvalid}: CredentialFormProps) {\n const {watch, setValue} = useFormContext();\n const appKey = watch('server.storage_dropbox_app_key');\n const appSecret = watch('server.storage_dropbox_app_secret');\n\n return (\n \n }\n required\n />\n }\n required\n />\n }\n required\n />\n {\n if (refreshToken) {\n setValue('server.storage_dropbox_refresh_token', refreshToken);\n }\n }}\n >\n \n \n \n \n \n \n );\n}\n\ninterface DropboxRefreshTokenDialogProps {\n appKey: string;\n appSecret: string;\n}\nfunction DropboxRefreshTokenDialog({\n appKey,\n appSecret,\n}: DropboxRefreshTokenDialogProps) {\n const form = useForm<{accessCode: string}>();\n const {formId, close} = useDialogContext();\n const generateRefreshToken = useGenerateDropboxRefreshToken();\n return (\n \n \n \n \n \n {\n generateRefreshToken.mutate(\n {\n app_key: appKey,\n app_secret: appSecret,\n access_code: data.accessCode,\n },\n {\n onSuccess: response => {\n close(response.refreshToken);\n },\n },\n );\n }}\n >\n
\n
\n \n
\n \n \n \n
\n }\n required\n />\n \n
\n \n {\n close();\n }}\n >\n \n \n \n \n \n \n
\n );\n}\n","import {useFormContext} from 'react-hook-form';\nimport {SettingsPanel} from '../../settings-panel';\nimport {FormSelect, Option} from '../../../../ui/forms/select/select';\nimport {AdminSettings} from '../../admin-settings';\nimport {SettingsErrorGroup} from '../../settings-error-group';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {FormSwitch} from '@common/ui/forms/toggle/switch';\nimport {FormRadioGroup} from '@common/ui/forms/radio-group/radio-group';\nimport {FormRadio} from '@common/ui/forms/radio-group/radio';\nimport {SectionHelper} from '@common/ui/section-helper';\nimport {useMaxServerUploadSize} from './max-server-upload-size';\nimport {SettingsSeparator} from '../../settings-separator';\nimport {JsonChipField} from '../../json-chip-field';\nimport {FormFileSizeField} from '@common/ui/forms/input-field/file-size-field';\nimport {Trans} from '@common/i18n/trans';\nimport {Fragment} from 'react';\nimport {useUploadS3Cors} from './use-upload-s3-cors';\nimport {Button} from '@common/ui/buttons/button';\nimport {DropboxForm} from './dropbox-form/dropbox-form';\nimport {useAdminSettings} from '../../requests/use-admin-settings';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {message} from '@common/i18n/message';\n\nexport function UploadingSettings() {\n const {trans} = useTrans();\n return (\n }\n description={\n \n }\n >\n \n \n \n \n {isInvalid => (\n }\n description={\n \n }\n >\n \n \n \n \n \n \n \n \n \n \n )}\n \n }\n placeholder=\"Infinity\"\n description={\n \n }\n />\n \n \n }\n description={\n \n }\n />\n }\n description={\n \n }\n />\n }\n placeholder={trans(message('Add extension...'))}\n description={\n \n }\n />\n }\n placeholder={trans(message('Add extension...'))}\n description={\n \n }\n />\n \n );\n}\n\nfunction MaxUploadSizeSection() {\n const {data} = useMaxServerUploadSize();\n return (\n :size\"\n values={{size: data?.maxSize, b: chunks => {chunks}}}\n />\n }\n />\n );\n}\n\nfunction PrivateUploadSection() {\n const {watch, clearErrors} = useFormContext();\n const isEnabled = watch('server.uploads_disk_driver');\n\n if (!isEnabled) return null;\n\n return (\n }\n description={\n \n }\n onSelectionChange={() => {\n clearErrors();\n }}\n >\n \n \n \n \n \n \n \n \n );\n}\n\nfunction PublicUploadSection() {\n const {watch, clearErrors} = useFormContext();\n const isEnabled = watch('server.public_disk_driver');\n\n if (!isEnabled) return null;\n\n return (\n }\n selectionMode=\"single\"\n name=\"server.public_disk_driver\"\n description={\n \n }\n onSelectionChange={() => {\n clearErrors();\n }}\n >\n \n \n \n \n \n \n );\n}\n\nfunction CredentialsSection() {\n const {watch} = useFormContext();\n const drives = [\n watch('server.uploads_disk_driver'),\n watch('server.public_disk_driver'),\n ];\n\n if (drives[0] === 'local' && drives[1] === 'local') {\n return null;\n }\n\n return (\n \n {isInvalid => {\n if (drives.includes('s3')) {\n return ;\n }\n if (drives.includes('ftp')) {\n return ;\n }\n if (drives.includes('dropbox')) {\n return ;\n }\n if (drives.includes('digitalocean_s3')) {\n return ;\n }\n if (drives.includes('backblaze_s3')) {\n return ;\n }\n }}\n \n );\n}\n\nexport interface CredentialFormProps {\n isInvalid: boolean;\n}\nfunction S3Form({isInvalid}: CredentialFormProps) {\n return (\n \n }\n required\n />\n }\n required\n />\n }\n pattern=\"[a-z1-9\\-]+\"\n placeholder=\"us-east-1\"\n />\n }\n required\n />\n }\n description={\n \n }\n />\n \n \n );\n}\n\nfunction DigitalOceanForm({isInvalid}: CredentialFormProps) {\n return (\n \n }\n required\n />\n }\n required\n />\n }\n pattern=\"[a-z0-9\\-]+\"\n placeholder=\"us-east-1\"\n required\n />\n }\n required\n />\n \n \n );\n}\n\nfunction BackblazeForm({isInvalid}: CredentialFormProps) {\n return (\n \n }\n required\n />\n }\n required\n />\n }\n pattern=\"[a-z0-9\\-]+\"\n placeholder=\"us-west-002\"\n required\n />\n }\n required\n />\n \n \n );\n}\n\ninterface S3DirectUploadFieldProps {\n invalid: boolean;\n}\nfunction S3DirectUploadField({invalid}: S3DirectUploadFieldProps) {\n const uploadCors = useUploadS3Cors();\n const {data: defaultSettings} = useAdminSettings();\n\n const s3DriverEnabled =\n defaultSettings?.server.uploads_disk_driver?.endsWith('s3') ||\n defaultSettings?.server.public_disk_driver?.endsWith('s3');\n\n return (\n \n \n

\n \n

\n

\n \n

\n \n }\n >\n \n \n {\n uploadCors.mutate();\n }}\n disabled={!s3DriverEnabled || uploadCors.isPending}\n >\n \n \n
\n );\n}\n\nfunction FtpForm({isInvalid}: CredentialFormProps) {\n return (\n <>\n }\n required\n />\n }\n required\n />\n }\n type=\"password\"\n required\n />\n }\n placeholder=\"/\"\n />\n }\n type=\"number\"\n min={0}\n placeholder=\"21\"\n />\n \n \n \n \n \n \n \n );\n}\n","import {Fragment} from 'react';\nimport {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';\nimport {Trans} from '../../../../i18n/trans';\n\nexport interface MailgunCredentialsProps {\n isInvalid: boolean;\n}\nexport function MailgunCredentials({isInvalid}: MailgunCredentialsProps) {\n return (\n \n }\n description={\n \n }\n required\n />\n }\n description={}\n required\n />\n }\n description={\n \n }\n placeholder=\"api.eu.mailgun.net\"\n />\n \n );\n}\n","import {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';\nimport {Trans} from '../../../../i18n/trans';\nimport {FormSelect} from '@common/ui/forms/select/select';\nimport {Item} from '@common/ui/forms/listbox/item';\n\nexport interface SmtpCredentialsProps {\n isInvalid: boolean;\n}\nexport function SmtpCredentials({isInvalid}: SmtpCredentialsProps) {\n return (\n <>\n }\n required\n />\n }\n required\n />\n }\n required\n />\n }\n />\n }\n >\n \n \n \n \n \n \n \n \n );\n}\n","import {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';\nimport {Trans} from '../../../../i18n/trans';\nimport {Fragment} from 'react';\n\nexport interface SesCredentialsProps {\n isInvalid: boolean;\n}\nexport function SesCredentials({isInvalid}: SesCredentialsProps) {\n return (\n \n }\n required\n />\n }\n required\n />\n }\n placeholder=\"us-east-1\"\n required\n />\n \n );\n}\n","import {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';\nimport {Trans} from '../../../../i18n/trans';\n\nexport interface PostmarkCredentialsProps {\n isInvalid: boolean;\n}\nexport function PostmarkCredentials({isInvalid}: PostmarkCredentialsProps) {\n return (\n }\n required\n />\n );\n}\n","import {createSvgIcon} from '../../../../icons/create-svg-icon';\n\nexport const GmailIcon = createSvgIcon(\n [\n ,\n ,\n ,\n ,\n ,\n ],\n 'Gmail',\n '0 0 48 48'\n);\n","import {useFormContext} from 'react-hook-form';\nimport {AdminSettings} from '../../admin-settings';\nimport {useSocialLogin} from '../../../../auth/requests/use-social-login';\nimport {toast} from '../../../../ui/toast/toast';\nimport {message} from '../../../../i18n/message';\nimport {Button} from '../../../../ui/buttons/button';\nimport {GmailIcon} from './gmail-icon';\nimport {Trans} from '../../../../i18n/trans';\nimport {Fragment} from 'react';\n\nexport function ConnectGmailPanel() {\n const {watch, setValue} = useFormContext();\n const {connectSocial} = useSocialLogin();\n const connectedEmail = watch('server.connectedGmailAccount');\n\n const handleGmailConnect = async () => {\n const e = await connectSocial('secure/settings/mail/gmail/connect');\n if (e?.status === 'SUCCESS') {\n const email = (e.callbackData as any).profile.email;\n setValue('server.connectedGmailAccount', email);\n toast(message('Connected gmail account: :email', {values: {email}}));\n }\n };\n\n const connectButton = (\n }\n onClick={() => {\n handleGmailConnect();\n }}\n >\n \n \n );\n\n const reconnectPanel = (\n
\n \n {connectedEmail}\n {\n handleGmailConnect();\n }}\n >\n \n \n
\n );\n\n return (\n \n
\n \n
\n {connectedEmail ? reconnectPanel : connectButton}\n
\n );\n}\n","import {useFormContext} from 'react-hook-form';\nimport {AdminSettings} from '../../admin-settings';\nimport {ComponentType, Fragment} from 'react';\nimport {MailgunCredentials} from './mailgun-credentials';\nimport {SmtpCredentials} from './smtp-credentials';\nimport {SesCredentials} from './ses-credentials';\nimport {PostmarkCredentials} from './postmark-credentials';\nimport {ConnectGmailPanel} from './connect-gmail-panel';\nimport {SettingsErrorGroup} from '../../settings-error-group';\nimport {FormSelect, Option} from '../../../../ui/forms/select/select';\nimport {Trans} from '../../../../i18n/trans';\nimport {LearnMoreLink} from '../../learn-more-link';\n\nexport function OutgoingMailGroup() {\n const {watch, clearErrors} = useFormContext();\n\n const selectedDriver = watch('server.mail_driver');\n const credentialForms: ComponentType<{isInvalid: boolean}>[] = [];\n\n if (selectedDriver === 'mailgun') {\n credentialForms.push(MailgunCredentials);\n }\n if (selectedDriver === 'smtp') {\n credentialForms.push(SmtpCredentials);\n }\n if (selectedDriver === 'ses') {\n credentialForms.push(SesCredentials);\n }\n if (selectedDriver === 'postmark') {\n credentialForms.push(PostmarkCredentials);\n }\n if (selectedDriver === 'gmailApi') {\n credentialForms.push(ConnectGmailPanel);\n }\n\n return (\n \n {isInvalid => (\n \n {\n clearErrors();\n }}\n invalid={isInvalid}\n selectionMode=\"single\"\n name=\"server.mail_driver\"\n label={}\n description={\n
\n \n \n
\n }\n >\n \n \n \n \n \n \n \n \n {credentialForms.length ? (\n
\n {credentialForms.map((CredentialsForm, index) => (\n \n ))}\n
\n ) : null}\n
\n )}\n \n );\n}\n","import {SettingsPanel} from '../../settings-panel';\nimport {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';\nimport {ExternalLink} from '../../../../ui/buttons/external-link';\nimport {SectionHelper} from '../../../../ui/section-helper';\nimport {SettingsSeparator} from '../../settings-separator';\nimport {Trans} from '../../../../i18n/trans';\nimport {OutgoingMailGroup} from './outgoing-mail-group';\nimport {useSettings} from '../../../../core/settings/use-settings';\n\nexport function OutgoingEmailSettings() {\n return (\n }\n description={\n \n }\n >\n }\n description={\n \n }\n required\n />\n \n }\n description={\n \n }\n required\n />\n \n }\n />\n \n \n \n );\n}\n\nfunction ContactAddressSection() {\n const {base_url} = useSettings();\n const contactPageUrl = `${base_url}/contact`;\n const link = (\n {contactPageUrl}\n );\n return (\n }\n description={\n \n }\n />\n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {toast} from '../../../../ui/toast/toast';\nimport {BackendResponse} from '../../../../http/backend-response/backend-response';\nimport {message} from '../../../../i18n/message';\nimport {apiClient} from '../../../../http/query-client';\nimport {showHttpErrorToast} from '../../../../utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {}\n\nfunction clearCache(): Promise {\n return apiClient.post('cache/flush').then(r => r.data);\n}\n\nexport function useClearCache() {\n return useMutation({\n mutationFn: () => clearCache(),\n onSuccess: () => {\n toast(message('Cache cleared'));\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n","import {useFormContext} from 'react-hook-form';\nimport {ComponentType} from 'react';\nimport {SettingsPanel} from '../../settings-panel';\nimport {FormSelect, Option} from '../../../../ui/forms/select/select';\nimport {SettingsErrorGroup} from '../../settings-error-group';\nimport {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';\nimport {AdminSettings} from '../../admin-settings';\nimport {useClearCache} from './clear-cache';\nimport {Button} from '../../../../ui/buttons/button';\nimport {SectionHelper} from '../../../../ui/section-helper';\nimport {Trans} from '../../../../i18n/trans';\n\nexport function CacheSettings() {\n const clearCache = useClearCache();\n return (\n }\n description={\n \n }\n >\n \n {\n clearCache.mutate();\n }}\n >\n \n \n \n }\n />\n \n );\n}\n\nfunction CacheSelect() {\n const {watch, clearErrors} = useFormContext();\n const cacheDriver = watch('server.cache_driver');\n\n let CredentialSection: ComponentType | null = null;\n if (cacheDriver === 'memcached') {\n CredentialSection = MemcachedCredentials;\n }\n\n return (\n \n {isInvalid => {\n return (\n <>\n {\n clearErrors();\n }}\n selectionMode=\"single\"\n name=\"server.cache_driver\"\n label={}\n description={\n \n }\n >\n \n \n \n \n \n \n {CredentialSection && (\n
\n \n
\n )}\n \n );\n }}\n
\n );\n}\n\ninterface CredentialProps {\n isInvalid: boolean;\n}\nfunction MemcachedCredentials({isInvalid}: CredentialProps) {\n return (\n <>\n }\n required\n />\n }\n required\n />\n \n );\n}\n","import {useFormContext} from 'react-hook-form';\nimport {SettingsPanel} from '@common/admin/settings/settings-panel';\nimport {SettingsErrorGroup} from '@common/admin/settings/settings-error-group';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {SectionHelper} from '@common/ui/section-helper';\nimport {ExternalLink} from '@common/ui/buttons/external-link';\nimport {Trans} from '@common/i18n/trans';\n\nexport function LoggingSettings() {\n return (\n }\n description={\n \n }\n >\n \n (\n {parts}\n ),\n }}\n message=\"Sentry integration provides real-time error tracking and helps identify and fix issues when site is in production.\"\n />\n }\n />\n \n );\n}\n\nfunction SentrySection() {\n const {clearErrors} = useFormContext();\n return (\n \n {isInvalid => {\n return (\n {\n clearErrors();\n }}\n invalid={isInvalid}\n name=\"server.sentry_dsn\"\n type=\"url\"\n minLength={30}\n label={}\n />\n );\n }}\n \n );\n}\n","import {useFormContext} from 'react-hook-form';\nimport {ComponentType} from 'react';\nimport {SettingsPanel} from '../settings-panel';\nimport {SettingsErrorGroup} from '../settings-error-group';\nimport {SectionHelper} from '../../../ui/section-helper';\nimport {AdminSettings} from '../admin-settings';\nimport {FormSelect, Option} from '../../../ui/forms/select/select';\nimport {FormTextField} from '../../../ui/forms/input-field/text-field/text-field';\nimport {Trans} from '../../../i18n/trans';\n\nexport function QueueSettings() {\n return (\n }\n description={\n \n }\n >\n \n }\n />\n \n }\n />\n \n \n );\n}\n\nfunction DriverSection() {\n const {watch, clearErrors} = useFormContext();\n const queueDriver = watch('server.queue_driver');\n\n let CredentialSection: ComponentType | null = null;\n if (queueDriver === 'sqs') {\n CredentialSection = SqsCredentials;\n }\n return (\n \n {isInvalid => {\n return (\n <>\n {\n clearErrors();\n }}\n selectionMode=\"single\"\n name=\"server.queue_driver\"\n label={}\n required\n >\n \n \n \n \n \n \n {CredentialSection && (\n
\n \n
\n )}\n \n );\n }}\n \n );\n}\n\ninterface CredentialProps {\n isInvalid: boolean;\n}\nfunction SqsCredentials({isInvalid}: CredentialProps) {\n return (\n <>\n }\n required\n />\n }\n required\n />\n }\n required\n />\n }\n required\n />\n }\n required\n />\n \n );\n}\n","import {useFormContext} from 'react-hook-form';\nimport {useContext} from 'react';\nimport {SettingsPanel} from '../settings-panel';\nimport {SettingsErrorGroup} from '../settings-error-group';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {FormSwitch} from '@common/ui/forms/toggle/switch';\nimport {SiteConfigContext} from '@common/core/settings/site-config-context';\nimport {Trans} from '@common/i18n/trans';\n\nexport function RecaptchaSettings() {\n const {settings} = useContext(SiteConfigContext);\n return (\n }\n description={\n \n }\n >\n {settings?.showRecaptchaLinkSwitch && (\n \n }\n >\n \n \n )}\n \n }\n >\n \n \n \n }\n >\n \n \n \n \n );\n}\n\nfunction RecaptchaSection() {\n const {clearErrors} = useFormContext();\n return (\n \n {isInvalid => {\n return (\n <>\n {\n clearErrors();\n }}\n invalid={isInvalid}\n name=\"client.recaptcha.site_key\"\n label={}\n />\n {\n clearErrors();\n }}\n invalid={isInvalid}\n name=\"client.recaptcha.secret_key\"\n label={}\n />\n \n );\n }}\n \n );\n}\n","import React, {ChangeEventHandler} from 'react';\nimport {mergeProps, useObjectRef} from '@react-aria/utils';\nimport {useController} from 'react-hook-form';\nimport clsx from 'clsx';\nimport {BaseFieldProps} from './base-field-props';\nimport {useField} from './use-field';\nimport {getInputFieldClassNames} from './get-input-field-class-names';\nimport {Field} from './field';\nimport {TextFieldProps} from './text-field/text-field';\n\nexport interface FileFieldProps\n extends Omit {\n onChange?: ChangeEventHandler<'input'>;\n accept?: string;\n}\nexport const FileField = React.forwardRef(\n (props, ref) => {\n const inputRef = useObjectRef(ref);\n\n const {fieldProps, inputProps} = useField({...props, focusRef: inputRef});\n\n const inputFieldClassNames = getInputFieldClassNames(props);\n\n return (\n \n \n \n );\n }\n);\n\nexport interface FormFileFieldProps extends FileFieldProps {\n name: string;\n}\nexport function FormFileField({name, ...props}: FormFileFieldProps) {\n const {\n field: {onChange, onBlur, ref},\n fieldState: {invalid, error},\n } = useController({\n name,\n });\n\n const [value, setValue] = React.useState('');\n\n const formProps: TextFieldProps = {\n onChange: e => {\n onChange(e.target.files?.[0]);\n setValue(e.target.value);\n },\n onBlur,\n value,\n invalid,\n errorMessage: error?.message,\n };\n\n return ;\n}\n","import {useFormContext} from 'react-hook-form';\nimport {SettingsPanel} from '../settings-panel';\nimport {SettingsErrorGroup} from '../settings-error-group';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {FormFileField} from '@common/ui/forms/input-field/file-field';\nimport {Trans} from '@common/i18n/trans';\nimport {Fragment} from 'react';\n\nexport function ReportsSettings() {\n return (\n }\n description={\n \n }\n >\n \n \n );\n}\n\nfunction AnalyticsSection() {\n const {clearErrors} = useFormContext();\n return (\n \n {isInvalid => (\n \n {\n clearErrors();\n }}\n invalid={isInvalid}\n name=\"files.certificate\"\n accept=\".json\"\n label={}\n />\n {\n clearErrors();\n }}\n invalid={isInvalid}\n name=\"server.analytics_property_id\"\n type=\"number\"\n label={}\n />\n {\n clearErrors();\n }}\n invalid={isInvalid}\n name=\"client.analytics.tracking_code\"\n placeholder=\"G-******\"\n min=\"1\"\n max=\"20\"\n description={\n \n }\n label={}\n />\n }\n description={\n \n }\n />\n \n )}\n \n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {UseFormReturn} from 'react-hook-form';\nimport {User} from '@common/auth/user';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {toast} from '@common/ui/toast/toast';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {onFormQueryError} from '@common/errors/on-form-query-error';\nimport {message} from '@common/i18n/message';\nimport {useNavigate} from '@common/utils/hooks/use-navigate';\n\ninterface Response extends BackendResponse {\n user: User;\n}\n\nexport interface UpdateUserPayload\n extends Omit, 'email_verified_at'> {\n email_verified_at?: boolean;\n id: number;\n}\n\nexport function useUpdateUser(form: UseFormReturn) {\n const navigate = useNavigate();\n return useMutation({\n mutationFn: (props: UpdateUserPayload) => updateUser(props),\n onSuccess: (response, props) => {\n toast(message('User updated'));\n queryClient.invalidateQueries({queryKey: ['users']});\n navigate('/admin/users');\n },\n onError: r => onFormQueryError(r, form),\n });\n}\n\nfunction updateUser({id, ...other}: UpdateUserPayload): Promise {\n if (other.roles) {\n other.roles = other.roles.map(r => r.id) as any;\n }\n return apiClient.put(`users/${id}`, other).then(r => r.data);\n}\n","import {FieldValues, SubmitHandler, UseFormReturn} from 'react-hook-form';\nimport clsx from 'clsx';\nimport {ReactNode} from 'react';\nimport {Link} from 'react-router-dom';\nimport {useValueLists} from '../../http/value-lists';\nimport {FormTextField} from '../../ui/forms/input-field/text-field/text-field';\nimport {FormSwitch} from '../../ui/forms/toggle/switch';\nimport {FormFileSizeField} from '../../ui/forms/input-field/file-size-field';\nimport {LinkStyle} from '../../ui/buttons/external-link';\nimport {FormPermissionSelector} from '../../auth/ui/permission-selector';\nimport {Trans} from '../../i18n/trans';\nimport {FormChipField} from '../../ui/forms/input-field/chip-field/form-chip-field';\nimport {Item} from '../../ui/forms/listbox/item';\nimport {CrupdateResourceLayout} from '../crupdate-resource-layout';\nimport {useSettings} from '../../core/settings/use-settings';\n\ninterface Props {\n onSubmit: SubmitHandler;\n form: UseFormReturn;\n title: ReactNode;\n subTitle?: ReactNode;\n isLoading: boolean;\n avatarManager: ReactNode;\n resendEmailButton?: ReactNode;\n children?: ReactNode;\n}\nexport function CrupdateUserForm({\n onSubmit,\n form,\n title,\n subTitle,\n isLoading,\n avatarManager,\n resendEmailButton,\n children,\n}: Props) {\n const {require_email_confirmation} = useSettings();\n const {data: valueLists} = useValueLists(['roles', 'permissions']);\n\n return (\n \n
\n {avatarManager}\n
\n {children}\n }\n />\n }\n />\n
\n
\n\n
\n \n }\n >\n \n \n {resendEmailButton}\n
\n }\n description={\n (\n \n {parts}\n \n ),\n }}\n message=\"Total storage space all user uploads are allowed to take up. If left empty, this value will be inherited from any roles or subscriptions user has, or from 'Available space' setting in Uploading settings page.\"\n />\n }\n />\n }\n suggestions={valueLists?.roles}\n >\n {chip => (\n \n {chip.name}\n \n )}\n \n
\n
\n \n
\n \n
\n \n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const ReportIcon = createSvgIcon(\n [,,,]\n, 'ReportOutlined');\n","import {useForm} from 'react-hook-form';\nimport {useParams} from 'react-router-dom';\nimport React, {useEffect} from 'react';\nimport {useUser} from '../../auth/ui/use-user';\nimport {UpdateUserPayload, useUpdateUser} from './requests/update-user';\nimport {Button} from '../../ui/buttons/button';\nimport {useResendVerificationEmail} from '../../auth/requests/use-resend-verification-email';\nimport {useUploadAvatar} from '../../auth/ui/account-settings/avatar/upload-avatar';\nimport {useRemoveAvatar} from '../../auth/ui/account-settings/avatar/remove-avatar';\nimport {CrupdateUserForm} from './crupdate-user-form';\nimport {User} from '../../auth/user';\nimport {Trans} from '../../i18n/trans';\nimport {FullPageLoader} from '../../ui/progress/full-page-loader';\nimport {useSettings} from '../../core/settings/use-settings';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';\nimport {FormImageSelector} from '@common/ui/images/image-selector';\nimport {queryClient} from '@common/http/query-client';\nimport {ReportIcon} from '@common/icons/material/Report';\n\nexport function UpdateUserPage() {\n const form = useForm();\n const {require_email_confirmation} = useSettings();\n const {userId} = useParams();\n const updateUser = useUpdateUser(form);\n const resendConfirmationEmail = useResendVerificationEmail();\n const {data, isLoading} = useUser(userId!, {\n with: ['subscriptions', 'roles', 'permissions', 'bans'],\n });\n const banReason = data?.user.bans?.[0]?.comment;\n\n useEffect(() => {\n if (data?.user && !form.getValues().id) {\n form.reset({\n first_name: data.user.first_name,\n last_name: data.user.last_name,\n roles: data.user.roles,\n permissions: data.user.permissions,\n id: data.user.id,\n email_verified_at: Boolean(data.user.email_verified_at),\n available_space: data.user.available_space,\n avatar: data.user.avatar,\n });\n }\n }, [data?.user, form]);\n\n if (isLoading) {\n return ;\n }\n\n const resendEmailButton = (\n {\n resendConfirmationEmail.mutate({email: data!.user.email});\n }}\n >\n \n \n );\n\n return (\n {\n updateUser.mutate(newValues);\n }}\n form={form}\n title={\n \n }\n subTitle={\n banReason && (\n
\n \n
\n \n
\n
\n )\n }\n isLoading={updateUser.isPending}\n avatarManager={\n {\n queryClient.invalidateQueries({queryKey: ['users']});\n }}\n />\n }\n resendEmailButton={resendEmailButton}\n >\n }\n />\n \n );\n}\n\ninterface AvatarSectionProps {\n user: User;\n onChange: () => void;\n}\nfunction AvatarSection({user, onChange}: AvatarSectionProps) {\n const uploadAvatar = useUploadAvatar({user});\n const removeAvatar = useRemoveAvatar({user});\n return (\n \n }\n previewSize=\"w-90 h-90\"\n showRemoveButton\n onChange={url => {\n if (url) {\n uploadAvatar.mutate({url});\n } else {\n removeAvatar.mutate();\n }\n onChange();\n }}\n />\n \n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {UseFormReturn} from 'react-hook-form';\nimport {User} from '../../../auth/user';\nimport {BackendResponse} from '../../../http/backend-response/backend-response';\nimport {toast} from '../../../ui/toast/toast';\nimport {apiClient, queryClient} from '../../../http/query-client';\nimport {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';\nimport {onFormQueryError} from '../../../errors/on-form-query-error';\nimport {message} from '../../../i18n/message';\nimport {useNavigate} from '../../../utils/hooks/use-navigate';\n\ninterface Response extends BackendResponse {\n user: User;\n}\n\nexport interface CreateUserPayload\n extends Omit, 'email_verified_at'> {\n email_verified_at?: boolean;\n}\n\nexport function useCreateUser(form: UseFormReturn) {\n const navigate = useNavigate();\n return useMutation({\n mutationFn: (props: CreateUserPayload) => createUser(props),\n onSuccess: () => {\n toast(message('User created'));\n queryClient.invalidateQueries({queryKey: DatatableDataQueryKey('users')});\n navigate('/admin/users');\n },\n onError: r => onFormQueryError(r, form),\n });\n}\n\nfunction createUser(payload: CreateUserPayload): Promise {\n if (payload.roles) {\n payload.roles = payload.roles.map(r => r.id) as any;\n }\n return apiClient.post('users', payload).then(r => r.data);\n}\n","import {useForm} from 'react-hook-form';\nimport React from 'react';\nimport {FormTextField} from '../../ui/forms/input-field/text-field/text-field';\nimport {CreateUserPayload, useCreateUser} from './requests/create-user';\nimport {CrupdateUserForm} from './crupdate-user-form';\nimport {FileUploadProvider} from '../../uploads/uploader/file-upload-provider';\nimport {Trans} from '../../i18n/trans';\nimport {FormImageSelector} from '@common/ui/images/image-selector';\n\nexport function CreateUserPage() {\n const form = useForm();\n const createUser = useCreateUser(form);\n\n const avatarManager = (\n \n }\n previewSize=\"w-90 h-90\"\n showRemoveButton\n />\n \n );\n\n return (\n {\n createUser.mutate(newValues);\n }}\n form={form}\n title={}\n isLoading={createUser.isPending}\n avatarManager={avatarManager}\n >\n }\n />\n }\n />\n \n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const TranslateIcon = createSvgIcon(\n \n, 'TranslateOutlined');\n","import {useQuery} from '@tanstack/react-query';\nimport {BackendResponse} from '../../http/backend-response/backend-response';\nimport {Localization} from '../../i18n/localization';\nimport {apiClient} from '../../http/query-client';\n\nexport interface FetchLocaleWithLinesResponse extends BackendResponse {\n localization: Localization;\n}\n\nexport const getLocalWithLinesQueryKey = (localeId?: number | string) => {\n const key: (string | number)[] = ['getLocaleWithLines'];\n if (localeId != null) {\n key.push(localeId);\n }\n return key;\n};\n\nexport function useLocaleWithLines(localeId: number | string) {\n return useQuery({\n queryKey: getLocalWithLinesQueryKey(localeId),\n queryFn: () => fetchLocaleWithLines(localeId),\n staleTime: Infinity,\n });\n}\n\nfunction fetchLocaleWithLines(\n localeId: number | string,\n): Promise {\n return apiClient\n .get(`localizations/${localeId}`)\n .then(response => response.data);\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {UseFormReturn} from 'react-hook-form';\nimport {toast} from '../../ui/toast/toast';\nimport {BackendResponse} from '../../http/backend-response/backend-response';\nimport {apiClient, queryClient} from '../../http/query-client';\nimport {message} from '../../i18n/message';\nimport {DatatableDataQueryKey} from '../../datatable/requests/paginated-resources';\nimport {Localization} from '../../i18n/localization';\nimport {onFormQueryError} from '../../errors/on-form-query-error';\nimport {showHttpErrorToast} from '../../utils/http/show-http-error-toast';\nimport {getLocalWithLinesQueryKey} from './use-locale-with-lines';\n\ninterface Response extends BackendResponse {\n localization: Localization;\n}\n\nfunction UpdateLocalization({\n id,\n ...other\n}: Partial): Promise {\n return apiClient.put(`localizations/${id}`, other).then(r => r.data);\n}\n\nexport function useUpdateLocalization(\n form?: UseFormReturn>,\n) {\n return useMutation({\n mutationFn: (props: Partial) => UpdateLocalization(props),\n onSuccess: () => {\n toast(message('Localization updated'));\n queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey('localizations'),\n });\n queryClient.invalidateQueries({queryKey: getLocalWithLinesQueryKey()});\n },\n onError: r => (form ? onFormQueryError(r, form) : showHttpErrorToast(r)),\n });\n}\n","import {useForm} from 'react-hook-form';\nimport {Dialog} from '../../ui/overlays/dialog/dialog';\nimport {DialogHeader} from '../../ui/overlays/dialog/dialog-header';\nimport {Trans} from '../../i18n/trans';\nimport {DialogBody} from '../../ui/overlays/dialog/dialog-body';\nimport {useDialogContext} from '../../ui/overlays/dialog/dialog-context';\nimport {Form} from '../../ui/forms/form';\nimport {Localization} from '../../i18n/localization';\nimport {FormTextField} from '../../ui/forms/input-field/text-field/text-field';\nimport {useValueLists} from '../../http/value-lists';\nimport {FormSelect, Option} from '../../ui/forms/select/select';\nimport {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';\nimport {Button} from '../../ui/buttons/button';\nimport {useUpdateLocalization} from './update-localization';\nimport {message} from '@common/i18n/message';\nimport {useTrans} from '@common/i18n/use-trans';\n\ninterface UpdateLocalizationDialogProps {\n localization: Localization;\n}\nexport function UpdateLocalizationDialog({\n localization,\n}: UpdateLocalizationDialogProps) {\n const {trans} = useTrans();\n const {formId, close} = useDialogContext();\n const form = useForm>({\n defaultValues: {\n id: localization.id,\n name: localization.name,\n language: localization.language,\n },\n });\n\n const {data} = useValueLists(['languages']);\n const languages = data?.languages || [];\n\n const updateLocalization = useUpdateLocalization(form);\n\n return (\n \n \n \n \n \n {\n updateLocalization.mutate(values, {onSuccess: close});\n }}\n >\n }\n className=\"mb-30\"\n required\n />\n }\n selectionMode=\"single\"\n showSearchField\n searchPlaceholder={trans(message('Search languages'))}\n >\n {languages.map(language => (\n \n ))}\n \n \n \n \n \n \n \n \n \n \n );\n}\n","import {useMutation, useQueryClient} from '@tanstack/react-query';\nimport {UseFormReturn} from 'react-hook-form';\nimport {toast} from '../../ui/toast/toast';\nimport {BackendResponse} from '../../http/backend-response/backend-response';\nimport {apiClient} from '../../http/query-client';\nimport {message} from '../../i18n/message';\nimport {DatatableDataQueryKey} from '../../datatable/requests/paginated-resources';\nimport {onFormQueryError} from '../../errors/on-form-query-error';\nimport {Localization} from '../../i18n/localization';\n\ninterface Response extends BackendResponse {\n localization: Localization;\n}\n\nexport interface CreateLocalizationPayload {\n name: string;\n language: string;\n}\n\nfunction createLocalization(\n payload: CreateLocalizationPayload,\n): Promise {\n return apiClient.post(`localizations`, payload).then(r => r.data);\n}\n\nexport function useCreateLocalization(\n form: UseFormReturn,\n) {\n const queryClient = useQueryClient();\n return useMutation({\n mutationFn: (props: CreateLocalizationPayload) => createLocalization(props),\n onSuccess: () => {\n toast(message('Localization created'));\n queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey('localizations'),\n });\n },\n onError: r => onFormQueryError(r, form),\n });\n}\n","import {useForm} from 'react-hook-form';\nimport {Dialog} from '../../ui/overlays/dialog/dialog';\nimport {DialogHeader} from '../../ui/overlays/dialog/dialog-header';\nimport {Trans} from '../../i18n/trans';\nimport {DialogBody} from '../../ui/overlays/dialog/dialog-body';\nimport {useDialogContext} from '../../ui/overlays/dialog/dialog-context';\nimport {Form} from '../../ui/forms/form';\nimport {FormTextField} from '../../ui/forms/input-field/text-field/text-field';\nimport {useValueLists} from '../../http/value-lists';\nimport {FormSelect, Option} from '../../ui/forms/select/select';\nimport {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';\nimport {Button} from '../../ui/buttons/button';\nimport {\n CreateLocalizationPayload,\n useCreateLocalization,\n} from './create-localization';\nimport {message} from '@common/i18n/message';\nimport {useTrans} from '@common/i18n/use-trans';\n\nexport function CreateLocationDialog() {\n const {trans} = useTrans();\n const {formId, close} = useDialogContext();\n const form = useForm({\n defaultValues: {\n language: 'en',\n },\n });\n\n const {data} = useValueLists(['languages']);\n const languages = data?.languages || [];\n\n const createLocalization = useCreateLocalization(form);\n\n return (\n \n \n \n \n \n {\n createLocalization.mutate(values, {onSuccess: close});\n }}\n >\n }\n className=\"mb-30\"\n required\n />\n }\n selectionMode=\"single\"\n showSearchField\n searchPlaceholder={trans(message('Search languages'))}\n >\n {languages.map(language => (\n \n ))}\n \n \n \n \n \n \n \n \n \n \n );\n}\n","export default \"__VITE_ASSET__5bb85b7d__\"","import {useMutation} from '@tanstack/react-query';\nimport {toast} from '../../ui/toast/toast';\nimport {BackendResponse} from '../../http/backend-response/backend-response';\nimport {apiClient, queryClient} from '../../http/query-client';\nimport {message} from '../../i18n/message';\nimport {DatatableDataQueryKey} from '../../datatable/requests/paginated-resources';\nimport {Localization} from '../../i18n/localization';\nimport {showHttpErrorToast} from '../../utils/http/show-http-error-toast';\nimport {getLocalWithLinesQueryKey} from './use-locale-with-lines';\nimport {UploadedFile} from '@common/uploads/uploaded-file';\n\ninterface Response extends BackendResponse {\n localization: Localization;\n}\n\ninterface Payload {\n file: UploadedFile;\n localeId: string | number;\n}\n\nexport function useUploadTranslationFile() {\n return useMutation({\n mutationFn: (payload: Payload) => uploadFile(payload),\n onSuccess: async () => {\n await queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey('localizations'),\n });\n await queryClient.invalidateQueries({\n queryKey: getLocalWithLinesQueryKey(),\n });\n toast(message('Translation file uploaded'));\n },\n onError: r => showHttpErrorToast(r),\n });\n}\n\nfunction uploadFile({localeId, file}: Payload): Promise {\n const data = new FormData();\n data.append('file', file.native);\n return apiClient\n .post(`localizations/${localeId}/upload`, data)\n .then(r => r.data);\n}\n","import React, {Fragment} from 'react';\nimport {Link} from 'react-router-dom';\nimport {DataTablePage} from '../../datatable/page/data-table-page';\nimport {IconButton} from '../../ui/buttons/icon-button';\nimport {FormattedDate} from '../../i18n/formatted-date';\nimport {ColumnConfig} from '../../datatable/column-config';\nimport {Trans} from '../../i18n/trans';\nimport {Localization} from '../../i18n/localization';\nimport {TranslateIcon} from '../../icons/material/Translate';\nimport {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';\nimport {UpdateLocalizationDialog} from './update-localization-dialog';\nimport {Tooltip} from '../../ui/tooltip/tooltip';\nimport {CreateLocationDialog} from './create-localization-dialog';\nimport {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';\nimport aroundTheWorldSvg from './around-the-world.svg';\nimport {DataTableAddItemButton} from '../../datatable/data-table-add-item-button';\nimport {DeleteSelectedItemsAction} from '../../datatable/page/delete-selected-items-action';\nimport {\n Menu,\n MenuItem,\n MenuTrigger,\n} from '@common/ui/navigation/menu/menu-trigger';\nimport {openDialog} from '@common/ui/overlays/store/dialog-store';\nimport {downloadFileFromUrl} from '@common/uploads/utils/download-file-from-url';\nimport {MoreVertIcon} from '@common/icons/material/MoreVert';\nimport {UploadInputType} from '@common/uploads/types/upload-input-config';\nimport {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';\nimport {useUploadTranslationFile} from '@common/admin/translations/use-upload-translation-file';\nimport {openUploadWindow} from '@common/uploads/utils/open-upload-window';\n\nconst columnConfig: ColumnConfig[] = [\n {\n key: 'name',\n allowsSorting: true,\n sortingKey: 'name',\n visibleInMode: 'all',\n width: 'flex-3 min-w-200',\n header: () => ,\n body: locale => locale.name,\n },\n {\n key: 'language',\n allowsSorting: true,\n sortingKey: 'language',\n header: () => ,\n body: locale => locale.language,\n },\n {\n key: 'updatedAt',\n allowsSorting: true,\n width: 'w-100',\n header: () => ,\n body: locale => ,\n },\n {\n key: 'actions',\n header: () => ,\n hideHeader: true,\n align: 'end',\n width: 'w-84 flex-shrink-0',\n visibleInMode: 'all',\n body: locale => {\n return (\n
\n }>\n \n \n \n \n\n \n \n \n
\n );\n },\n },\n];\n\nexport function LocalizationIndex() {\n return (\n }\n columns={columnConfig}\n actions={}\n selectedActions={}\n emptyStateMessage={\n }\n filteringTitle={}\n />\n }\n />\n );\n}\n\nfunction Actions() {\n return (\n \n \n \n \n \n \n \n \n );\n}\n\ninterface RowActionsMenuTriggerProps {\n locale: Localization;\n}\nfunction RowActionsMenuTrigger({locale}: RowActionsMenuTriggerProps) {\n const uploadFile = useUploadTranslationFile();\n return (\n \n \n \n \n \n \n \n \n \n openDialog(UpdateLocalizationDialog, {localization: locale})\n }\n >\n \n \n \n downloadFileFromUrl(`api/v1/localizations/${locale.id}/download`)\n }\n >\n \n \n {\n const files = await openUploadWindow({\n types: [UploadInputType.json],\n });\n if (files.length == 1) {\n uploadFile.mutate({localeId: locale.id, file: files[0]});\n }\n }}\n >\n \n \n \n \n );\n}\n","import {useForm} from 'react-hook-form';\nimport {Dialog} from '../../ui/overlays/dialog/dialog';\nimport {DialogHeader} from '../../ui/overlays/dialog/dialog-header';\nimport {Trans} from '../../i18n/trans';\nimport {DialogBody} from '../../ui/overlays/dialog/dialog-body';\nimport {useDialogContext} from '../../ui/overlays/dialog/dialog-context';\nimport {Form} from '../../ui/forms/form';\nimport {FormTextField} from '../../ui/forms/input-field/text-field/text-field';\nimport {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';\nimport {Button} from '../../ui/buttons/button';\nimport {SectionHelper} from '../../ui/section-helper';\n\ninterface FormValue {\n key: string;\n value: string;\n}\n\nexport function NewTranslationDialog() {\n const {formId, close} = useDialogContext();\n const form = useForm();\n\n return (\n \n \n \n \n \n {\n close(values);\n }}\n >\n \n }\n description={\n \n }\n />\n }\n className=\"mb-30\"\n required\n />\n }\n required\n />\n \n \n \n \n \n \n \n );\n}\n","import React, {useMemo, useRef, useState} from 'react';\nimport {useParams} from 'react-router-dom';\nimport {useLocaleWithLines} from './use-locale-with-lines';\nimport {Trans} from '../../i18n/trans';\nimport {IconButton} from '../../ui/buttons/icon-button';\nimport {Button} from '../../ui/buttons/button';\nimport {Breadcrumb} from '../../ui/breadcrumbs/breadcrumb';\nimport {BreadcrumbItem} from '../../ui/breadcrumbs/breadcrumb-item';\nimport {TextField} from '../../ui/forms/input-field/text-field/text-field';\nimport {useTrans} from '../../i18n/use-trans';\nimport {SearchIcon} from '../../icons/material/Search';\nimport {CloseIcon} from '../../icons/material/Close';\nimport {AddIcon} from '../../icons/material/Add';\nimport {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';\nimport {NewTranslationDialog} from './new-translation-dialog';\nimport {useUpdateLocalization} from './update-localization';\nimport {Localization} from '../../i18n/localization';\nimport {FullPageLoader} from '../../ui/progress/full-page-loader';\nimport {useIsMobileMediaQuery} from '../../utils/hooks/is-mobile-media-query';\nimport {useVirtualizer} from '@tanstack/react-virtual';\nimport {useNavigate} from '../../utils/hooks/use-navigate';\nimport {useUploadTranslationFile} from '@common/admin/translations/use-upload-translation-file';\nimport {\n Menu,\n MenuItem,\n MenuTrigger,\n} from '@common/ui/navigation/menu/menu-trigger';\nimport {MoreVertIcon} from '@common/icons/material/MoreVert';\nimport {downloadFileFromUrl} from '@common/uploads/utils/download-file-from-url';\nimport {openUploadWindow} from '@common/uploads/utils/open-upload-window';\nimport {UploadInputType} from '@common/uploads/types/upload-input-config';\n\ntype Lines = Record;\n\nexport function TranslationManagementPage() {\n const {localeId} = useParams();\n\n const {data, isLoading} = useLocaleWithLines(localeId!);\n const localization = data?.localization;\n\n if (isLoading || !localization) {\n return ;\n }\n\n return
;\n}\n\ninterface FormProps {\n localization: Localization;\n}\nfunction Form({localization}: FormProps) {\n const [lines, setLines] = useState(localization.lines || {});\n\n const navigate = useNavigate();\n const updateLocalization = useUpdateLocalization();\n const [searchQuery, setSearchQuery] = useState('');\n\n return (\n {\n e.preventDefault();\n updateLocalization.mutate(\n {id: localization.id, lines},\n {\n onSuccess: () => {\n navigate('/admin/localizations');\n },\n },\n );\n }}\n >\n \n \n \n );\n}\n\ninterface HeaderProps {\n localization: Localization;\n lines: Lines;\n setLines: (lines: Lines) => void;\n searchQuery: string;\n setSearchQuery: (value: string) => void;\n isLoading: boolean;\n}\nfunction Header({\n localization,\n searchQuery,\n setSearchQuery,\n isLoading,\n lines,\n setLines,\n}: HeaderProps) {\n const navigate = useNavigate();\n const isMobile = useIsMobileMediaQuery();\n const {trans} = useTrans();\n\n return (\n
\n \n {\n navigate('/admin/localizations');\n }}\n >\n \n \n \n \n \n \n
\n
\n setSearchQuery(e.target.value)}\n startAdornment={}\n placeholder={trans({message: 'Type to search...'})}\n />\n
\n {\n if (newTranslation) {\n const newLines = {...lines};\n newLines[newTranslation.key] = newTranslation.value;\n setLines(newLines);\n }\n }}\n >\n {!isMobile && (\n }\n >\n \n \n )}\n \n \n \n \n {isMobile ? (\n \n ) : (\n \n )}\n \n
\n
\n );\n}\n\ninterface LinesListProps {\n searchQuery?: string;\n lines: Lines;\n setLines: (lines: Lines) => void;\n}\nfunction LinesList({searchQuery, lines, setLines}: LinesListProps) {\n const filteredLines = useMemo(() => {\n return Object.entries(lines).filter(([id, translation]) => {\n const lowerCaseQuery = searchQuery?.toLowerCase();\n return (\n !lowerCaseQuery ||\n id?.toLowerCase().includes(lowerCaseQuery) ||\n translation?.toLowerCase().includes(lowerCaseQuery)\n );\n });\n }, [lines, searchQuery]);\n\n const ref = useRef(null);\n const rowVirtualizer = useVirtualizer({\n count: filteredLines.length,\n getScrollElement: () => ref.current,\n estimateSize: () => 123,\n });\n\n return (\n
\n \n {rowVirtualizer.getVirtualItems().map(virtualItem => {\n const [id, translation] = filteredLines[virtualItem.index];\n return (\n \n
\n
\n \n {id}\n \n {\n const newLines = {...lines};\n delete newLines[id];\n setLines(newLines);\n }}\n >\n \n \n
\n
\n {\n const newLines = {...lines};\n newLines[id] = e.target.value;\n setLines(newLines);\n }}\n />\n
\n
\n
\n );\n })}\n \n \n );\n}\n\ninterface ActionsMenuTriggerProps {\n locale: Localization;\n}\nfunction ActionsMenuTrigger({locale}: ActionsMenuTriggerProps) {\n const uploadFile = useUploadTranslationFile();\n return (\n \n \n \n \n \n \n downloadFileFromUrl(`api/v1/localizations/${locale.id}/download`)\n }\n >\n \n \n {\n const files = await openUploadWindow({\n types: [UploadInputType.json],\n });\n if (files.length == 1) {\n uploadFile.mutate({localeId: locale.id, file: files[0]});\n }\n }}\n >\n \n \n \n \n );\n}\n","import {useContext} from 'react';\nimport {\n AdConfig,\n SiteConfigContext,\n} from '../../core/settings/site-config-context';\nimport {Form} from '../../ui/forms/form';\nimport {useForm} from 'react-hook-form';\nimport {FormTextField} from '../../ui/forms/input-field/text-field/text-field';\nimport {Trans} from '../../i18n/trans';\nimport {Button} from '../../ui/buttons/button';\nimport {FormSwitch} from '../../ui/forms/toggle/switch';\nimport {useAdminSettings} from '../settings/requests/use-admin-settings';\nimport {ProgressCircle} from '../../ui/progress/progress-circle';\nimport {Settings} from '../../core/settings/settings';\nimport {\n AdminSettingsWithFiles,\n useUpdateAdminSettings,\n} from '../settings/requests/update-admin-settings';\nimport {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';\nimport {ImageZoomDialog} from '../../ui/overlays/dialog/image-zoom-dialog';\nimport {StaticPageTitle} from '../../seo/static-page-title';\n\nexport function AdsPage() {\n const query = useAdminSettings();\n\n return (\n
\n \n \n \n

\n \n

\n {query.isLoading ? (\n \n ) : (\n \n )}\n
\n );\n}\n\ninterface AdsFormProps {\n defaultValues: Settings['ads'];\n}\nfunction AdsForm({defaultValues}: AdsFormProps) {\n const {\n admin: {ads},\n } = useContext(SiteConfigContext);\n\n const form = useForm({\n defaultValues: {client: {ads: defaultValues}},\n });\n const updateSettings = useUpdateAdminSettings(form);\n\n return (\n {\n updateSettings.mutate(value);\n }}\n >\n {ads.map(ad => {\n return ;\n })}\n \n }\n >\n \n \n \n \n \n \n );\n}\n\ninterface AdSectionProps {\n adConfig: AdConfig;\n}\nfunction AdSection({adConfig}: AdSectionProps) {\n return (\n
\n }\n />\n \n \n \n \n \n \n
\n );\n}\n","import {NavLink} from 'react-router-dom';\nimport {AppearanceButton} from './appearance-button';\nimport {useAppearanceStore} from './appearance-store';\nimport {Trans} from '../../i18n/trans';\nimport {Fragment, useMemo} from 'react';\n\nexport function SectionList() {\n const sections = useAppearanceStore(s => s.config?.sections);\n const sortedSection = useMemo(() => {\n if (!sections) return [];\n return Object.entries(sections || [])\n .map(([key, value]) => {\n return {\n ...value,\n key,\n };\n })\n .sort((a, b) => (a?.position || 1) - (b?.position || 1));\n }, [sections]);\n\n return (\n \n {sortedSection.map(section => {\n return (\n \n \n \n );\n })}\n \n );\n}\n","import {\n BackendFilter,\n FilterControlType,\n FilterOperator,\n} from '../../datatable/filters/backend-filter';\nimport {message} from '../../i18n/message';\nimport {\n createdAtFilter,\n updatedAtFilter,\n} from '@common/datatable/filters/timestamp-filters';\n\nexport const RoleIndexPageFilters: BackendFilter[] = [\n {\n key: 'type',\n label: message('Type'),\n description: message('Type of the role'),\n defaultOperator: FilterOperator.ne,\n control: {\n type: FilterControlType.Select,\n defaultValue: '01',\n options: [\n {\n key: '01',\n label: message('Sitewide'),\n value: 'sitewide',\n },\n {\n key: '02',\n label: message('Workspace'),\n value: 'workspace',\n },\n ],\n },\n },\n createdAtFilter({\n description: message('Date role was created'),\n }),\n updatedAtFilter({\n description: message('Date role was last updated'),\n }),\n];\n","import React, {Fragment} from 'react';\nimport {Link} from 'react-router-dom';\nimport {DataTablePage} from '../../datatable/page/data-table-page';\nimport {IconButton} from '../../ui/buttons/icon-button';\nimport {EditIcon} from '../../icons/material/Edit';\nimport {FormattedDate} from '../../i18n/formatted-date';\nimport {ColumnConfig} from '../../datatable/column-config';\nimport {Trans} from '../../i18n/trans';\nimport {Role} from '../../auth/role';\nimport teamSvg from './team.svg';\nimport {DeleteSelectedItemsAction} from '../../datatable/page/delete-selected-items-action';\nimport {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';\nimport {RoleIndexPageFilters} from './role-index-page-filters';\nimport {DataTableExportCsvButton} from '../../datatable/csv-export/data-table-export-csv-button';\nimport {DataTableAddItemButton} from '../../datatable/data-table-add-item-button';\n\nconst columnConfig: ColumnConfig[] = [\n {\n key: 'name',\n allowsSorting: true,\n visibleInMode: 'all',\n header: () => ,\n body: role => (\n
\n
\n \n
\n
\n {role.description ? : undefined}\n
\n
\n ),\n },\n {\n key: 'type',\n maxWidth: 'max-w-100',\n allowsSorting: true,\n header: () => ,\n body: role => ,\n },\n {\n key: 'updated_at',\n maxWidth: 'max-w-100',\n allowsSorting: true,\n header: () => ,\n body: role => ,\n },\n {\n key: 'actions',\n header: () => ,\n hideHeader: true,\n visibleInMode: 'all',\n align: 'end',\n width: 'w-42 flex-shrink-0',\n body: role => {\n return (\n \n \n \n \n \n );\n },\n },\n];\n\nexport function RolesIndexPage() {\n return (\n }\n columns={columnConfig}\n filters={RoleIndexPageFilters}\n actions={}\n selectedActions={}\n emptyStateMessage={\n }\n filteringTitle={}\n />\n }\n />\n );\n}\n\nfunction Actions() {\n return (\n \n \n \n \n \n \n );\n}\n","import {useQuery} from '@tanstack/react-query';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {apiClient} from '@common/http/query-client';\nimport {Role} from '@common/auth/role';\nimport {useParams} from 'react-router-dom';\n\nconst Endpoint = (id: number | string) => `roles/${id}`;\n\nexport interface FetchRoleResponse extends BackendResponse {\n role: Role;\n}\n\nexport function useRole() {\n const {roleId} = useParams();\n return useQuery({\n queryKey: [Endpoint(roleId!)],\n queryFn: () => fetchRole(roleId!),\n });\n}\n\nfunction fetchRole(roleId: number | string): Promise {\n return apiClient.get(Endpoint(roleId)).then(response => response.data);\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient, queryClient} from '../../../http/query-client';\nimport {BackendResponse} from '../../../http/backend-response/backend-response';\nimport {toast} from '../../../ui/toast/toast';\nimport {Role} from '../../../auth/role';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {message} from '../../../i18n/message';\nimport {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';\nimport {showHttpErrorToast} from '../../../utils/http/show-http-error-toast';\nimport {useNavigate} from '../../../utils/hooks/use-navigate';\n\ninterface Response extends BackendResponse {\n role: Role;\n}\n\ninterface Payload extends Partial {\n id: number;\n}\n\nconst Endpoint = (id: number) => `roles/${id}`;\n\nexport function useUpdateRole() {\n const {trans} = useTrans();\n const navigate = useNavigate();\n return useMutation({\n mutationFn: (payload: Payload) => updateRole(payload),\n onSuccess: response => {\n toast(trans(message('Role updated')));\n queryClient.invalidateQueries({queryKey: [Endpoint(response.role.id)]});\n queryClient.invalidateQueries({queryKey: DatatableDataQueryKey('roles')});\n navigate('/admin/roles');\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction updateRole({id, ...payload}: Payload): Promise {\n return apiClient.put(Endpoint(id), payload).then(r => r.data);\n}\n","import {Role} from '../../../auth/role';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {useFormContext} from 'react-hook-form';\nimport {FormTextField} from '../../../ui/forms/input-field/text-field/text-field';\nimport {Trans} from '../../../i18n/trans';\nimport {message} from '../../../i18n/message';\nimport {FormSelect} from '../../../ui/forms/select/select';\nimport {Item} from '../../../ui/forms/listbox/item';\nimport {FormSwitch} from '../../../ui/forms/toggle/switch';\nimport {FormPermissionSelector} from '../../../auth/ui/permission-selector';\nimport {useSettings} from '../../../core/settings/use-settings';\nimport {Button} from '@common/ui/buttons/button';\n\ninterface CrupdateRolePageSettingsPanelProps {\n isInternal?: boolean;\n}\nexport function CrupdateRolePageSettingsPanel({\n isInternal = false,\n}: CrupdateRolePageSettingsPanelProps) {\n const {trans} = useTrans();\n const {workspaces} = useSettings();\n const {watch, setValue} = useFormContext();\n const watchedType = watch('type');\n\n return (\n <>\n }\n name=\"name\"\n className=\"mb-20\"\n required\n />\n }\n name=\"description\"\n inputElementType=\"textarea\"\n placeholder={trans(message('Role description...'))}\n rows={4}\n className=\"mb-20\"\n />\n {workspaces.integrated && (\n }\n name=\"type\"\n selectionMode=\"single\"\n className=\"mb-20\"\n description={\n \n }\n >\n \n \n \n \n \n \n \n )}\n {!isInternal && (\n <>\n \n }\n >\n \n \n {watchedType === 'sitewide' && (\n \n }\n >\n \n \n )}\n \n )}\n
\n

\n \n

\n setValue('permissions', [])}\n >\n \n \n
\n \n \n );\n}\n","import {Dialog} from '../ui/overlays/dialog/dialog';\nimport {DialogHeader} from '../ui/overlays/dialog/dialog-header';\nimport {Trans} from '../i18n/trans';\nimport {DialogBody} from '../ui/overlays/dialog/dialog-body';\nimport {TextField} from '../ui/forms/input-field/text-field/text-field';\nimport {SearchIcon} from '../icons/material/Search';\nimport {useState} from 'react';\nimport {useTrans} from '../i18n/use-trans';\nimport {message} from '../i18n/message';\nimport {Avatar} from '../ui/images/avatar';\nimport {NormalizedModel} from '../datatable/filters/normalized-model';\nimport {IllustratedMessage} from '../ui/images/illustrated-message';\nimport {SvgImage} from '../ui/images/svg-image/svg-image';\nimport teamSvg from '../admin/roles/team.svg';\nimport {useDialogContext} from '../ui/overlays/dialog/dialog-context';\nimport {useNormalizedModels} from './queries/use-normalized-models';\n\ninterface SelectUserDialogProps {\n onUserSelected: (user: NormalizedModel) => void;\n}\n\nexport function SelectUserDialog({onUserSelected}: SelectUserDialogProps) {\n const {close} = useDialogContext();\n const [searchTerm, setSearchTerm] = useState('');\n const {trans} = useTrans();\n const query = useNormalizedModels('normalized-models/user', {\n query: searchTerm,\n perPage: 14,\n });\n const users = query.data?.results || [];\n\n const emptyStateMessage = (\n }\n description={}\n image={}\n />\n );\n\n const selectUser = (user: NormalizedModel) => {\n close();\n onUserSelected(user);\n };\n\n return (\n \n \n \n \n \n }\n placeholder={trans(message('Search for user by name or email'))}\n value={searchTerm}\n onChange={e => {\n setSearchTerm(e.target.value);\n }}\n />\n {!query.isLoading && !users.length && emptyStateMessage}\n
\n {users.map(user => (\n \n ))}\n
\n
\n
\n );\n}\n\ninterface UserListItemProps {\n user: NormalizedModel;\n onUserSelected: (user: NormalizedModel) => void;\n}\nfunction UserListItem({user, onUserSelected}: UserListItemProps) {\n return (\n {\n onUserSelected(user);\n }}\n onKeyDown={e => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n onUserSelected(user);\n }\n }}\n >\n \n
\n
{user.name}
\n
\n {user.description}\n
\n
\n \n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient} from '../../../http/query-client';\nimport {BackendResponse} from '../../../http/backend-response/backend-response';\nimport {toast} from '../../../ui/toast/toast';\nimport {message} from '../../../i18n/message';\nimport {Role} from '../../../auth/role';\nimport {showHttpErrorToast} from '../../../utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {}\n\ninterface Payload {\n userIds: number[];\n}\n\nexport function useRemoveUsersFromRole(role: Role) {\n return useMutation({\n mutationFn: ({userIds}: Payload) =>\n removeUsersFromRole({userIds, roleId: role.id}),\n onSuccess: (response, payload) => {\n toast(\n message('Removed [one 1 user|other :count users] from “{role}“', {\n values: {count: payload.userIds.length, role: role.name},\n }),\n );\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction removeUsersFromRole({\n roleId,\n userIds,\n}: Payload & {roleId: number}): Promise {\n return apiClient\n .post(`roles/${roleId}/remove-users`, {userIds})\n .then(r => r.data);\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient} from '../../../http/query-client';\nimport {BackendResponse} from '../../../http/backend-response/backend-response';\nimport {toast} from '../../../ui/toast/toast';\nimport {message} from '../../../i18n/message';\nimport {Role} from '../../../auth/role';\nimport {showHttpErrorToast} from '../../../utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {}\n\ninterface Payload {\n userIds: number[];\n}\n\nexport function useAddUsersToRole(role: Role) {\n return useMutation({\n mutationFn: ({userIds}: Payload) =>\n addUsersToRole({userIds, roleId: role.id}),\n onSuccess: (response, payload) => {\n toast(\n message('Assigned [one 1 user|other :count users] to {role}', {\n values: {count: payload.userIds.length, role: role.name},\n }),\n );\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction addUsersToRole({\n roleId,\n userIds,\n}: Payload & {roleId: number}): Promise {\n return apiClient\n .post(`roles/${roleId}/add-users`, {userIds})\n .then(r => r.data);\n}\n","import {Role} from '../../../auth/role';\nimport {ColumnConfig} from '../../../datatable/column-config';\nimport {User} from '../../../auth/user';\nimport {Trans} from '../../../i18n/trans';\nimport {NameWithAvatar} from '../../../datatable/column-templates/name-with-avatar';\nimport {FormattedDate} from '../../../i18n/formatted-date';\nimport React from 'react';\nimport teamSvg from '../team.svg';\nimport {DialogTrigger} from '../../../ui/overlays/dialog/dialog-trigger';\nimport {Button} from '../../../ui/buttons/button';\nimport {SelectUserDialog} from '../../../users/select-user-dialog';\nimport {queryClient} from '../../../http/query-client';\nimport {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';\nimport {DataTableEmptyStateMessage} from '../../../datatable/page/data-table-emty-state-message';\nimport {useDataTable} from '../../../datatable/page/data-table-context';\nimport {ConfirmationDialog} from '../../../ui/overlays/dialog/confirmation-dialog';\nimport {useRemoveUsersFromRole} from '../requests/use-remove-users-from-role';\nimport {useAddUsersToRole} from '../requests/use-add-users-to-role';\nimport {DataTable} from '../../../datatable/data-table';\nimport {useIsMobileMediaQuery} from '../../../utils/hooks/is-mobile-media-query';\n\nconst userColumn: ColumnConfig = {\n key: 'name',\n allowsSorting: true,\n sortingKey: 'email',\n header: () => ,\n body: user => (\n \n ),\n width: 'col-w-3',\n};\n\nconst desktopColumns: ColumnConfig[] = [\n userColumn,\n {\n key: 'first_name',\n allowsSorting: true,\n header: () => ,\n body: user => user.first_name,\n },\n {\n key: 'last_name',\n allowsSorting: true,\n header: () => ,\n body: user => user.last_name,\n },\n {\n key: 'created_at',\n allowsSorting: true,\n header: () => ,\n body: user => ,\n },\n];\n\nconst mobileColumns: ColumnConfig[] = [userColumn];\n\ninterface CrupdateRolePageUsersPanelProps {\n role: Role;\n}\nexport function EditRolePageUsersPanel({\n role,\n}: CrupdateRolePageUsersPanelProps) {\n const isMobile = useIsMobileMediaQuery();\n\n if (role.guests || role.type === 'workspace') {\n return (\n
\n }\n />\n
\n );\n }\n\n return (\n }\n selectedActions={}\n emptyStateMessage={\n \n }\n filteringTitle={}\n />\n }\n />\n );\n}\n\ninterface AssignUserActionProps {\n role: Role;\n}\nfunction AssignUserAction({role}: AssignUserActionProps) {\n const addUsers = useAddUsersToRole(role);\n return (\n \n \n {\n addUsers.mutate(\n {userIds: [user.id as number]},\n {\n onSuccess: () => {\n queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey('users', {\n roleId: `${role.id}`,\n }),\n });\n },\n },\n );\n }}\n />\n \n );\n}\n\ntype RemoveUsersActionProps = {\n role: Role;\n};\nexport function RemoveUsersAction({role}: RemoveUsersActionProps) {\n const removeUsers = useRemoveUsersFromRole(role);\n const {selectedRows} = useDataTable();\n\n return (\n {\n if (isConfirmed) {\n removeUsers.mutate(\n {userIds: selectedRows as number[]},\n {\n onSuccess: () => {\n queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey('users', {\n roleId: `${role.id}`,\n }),\n });\n },\n },\n );\n }\n }}\n >\n \n \n }\n body={}\n confirm={}\n isDanger\n />\n \n );\n}\n","import {useRole} from '../requests/use-role';\nimport {FullPageLoader} from '../../../ui/progress/full-page-loader';\nimport {Role} from '../../../auth/role';\nimport {Trans} from '../../../i18n/trans';\nimport {useForm} from 'react-hook-form';\nimport {Tabs} from '../../../ui/tabs/tabs';\nimport {Tab} from '../../../ui/tabs/tab';\nimport {TabList} from '../../../ui/tabs/tab-list';\nimport {TabPanel, TabPanels} from '../../../ui/tabs/tab-panels';\nimport {useUpdateRole} from '../requests/use-update-role';\nimport {CrupdateResourceLayout} from '../../crupdate-resource-layout';\nimport {CrupdateRolePageSettingsPanel} from './crupdate-role-settings-panel';\nimport {EditRolePageUsersPanel} from './edit-role-page-users-panel';\n\nexport function EditRolePage() {\n const query = useRole();\n\n if (query.status !== 'success') {\n return ;\n }\n\n return ;\n}\n\ninterface PageContentProps {\n role: Role;\n}\nfunction PageContent({role}: PageContentProps) {\n const form = useForm({defaultValues: role});\n const updateRole = useUpdateRole();\n\n return (\n {\n updateRole.mutate(values);\n }}\n title={}\n isLoading={updateRole.isPending}\n >\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient, queryClient} from '../../../http/query-client';\nimport {BackendResponse} from '../../../http/backend-response/backend-response';\nimport {toast} from '../../../ui/toast/toast';\nimport {Role} from '../../../auth/role';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {message} from '../../../i18n/message';\nimport {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';\nimport {onFormQueryError} from '../../../errors/on-form-query-error';\nimport {UseFormReturn} from 'react-hook-form';\n\ninterface Response extends BackendResponse {\n role: Role;\n}\n\nexport interface CreateRolePayload extends Partial {}\n\nconst Endpoint = 'roles';\n\nexport function useCreateRole(form: UseFormReturn) {\n const {trans} = useTrans();\n return useMutation({\n mutationFn: (payload: CreateRolePayload) => createRole(payload),\n onSuccess: () => {\n toast(trans(message('Created new role')));\n queryClient.invalidateQueries({queryKey: DatatableDataQueryKey('roles')});\n },\n onError: r => onFormQueryError(r, form),\n });\n}\n\nfunction createRole({id, ...payload}: CreateRolePayload): Promise {\n return apiClient.post(Endpoint, payload).then(r => r.data);\n}\n","import {useForm} from 'react-hook-form';\nimport {CrupdateResourceLayout} from '../../crupdate-resource-layout';\nimport {Trans} from '../../../i18n/trans';\nimport {CrupdateRolePageSettingsPanel} from './crupdate-role-settings-panel';\nimport {CreateRolePayload, useCreateRole} from '../requests/user-create-role';\nimport {useNavigate} from '../../../utils/hooks/use-navigate';\n\nexport function CreateRolePage() {\n const form = useForm({defaultValues: {type: 'sitewide'}});\n const createRole = useCreateRole(form);\n const navigate = useNavigate();\n\n return (\n {\n createRole.mutate(values, {\n onSuccess: response => {\n navigate(`/admin/roles/${response.role.id}/edit`);\n },\n });\n }}\n title={}\n isLoading={createRole.isPending}\n >\n \n \n );\n}\n","import {\n BackendFilter,\n FilterControlType,\n FilterOperator,\n} from '../../datatable/filters/backend-filter';\nimport {message} from '../../i18n/message';\nimport {TagType} from '../../core/settings/site-config-context';\nimport {\n createdAtFilter,\n updatedAtFilter,\n} from '@common/datatable/filters/timestamp-filters';\n\nexport const TagIndexPageFilters = (types: TagType[]): BackendFilter[] => {\n return [\n {\n key: 'type',\n label: message('Type'),\n description: message('Type of the tag'),\n defaultOperator: FilterOperator.ne,\n control: {\n type: FilterControlType.Select,\n defaultValue: types[0].name,\n options: types.map(type => ({\n key: type.name,\n label: message(type.name),\n value: type.name,\n })),\n },\n },\n createdAtFilter({\n description: message('Date tag was created'),\n }),\n updatedAtFilter({\n description: message('Date tag was last updated'),\n }),\n ];\n};\n","export default \"__VITE_ASSET__8de61ea9__\"","import {Tag} from '../../tags/tag';\nimport {UseFormReturn} from 'react-hook-form';\nimport {Form} from '../../ui/forms/form';\nimport {FormTextField} from '../../ui/forms/input-field/text-field/text-field';\nimport {FormSelect} from '../../ui/forms/select/select';\nimport {Trans} from '../../i18n/trans';\nimport {Item} from '../../ui/forms/listbox/item';\nimport {useContext} from 'react';\nimport {SiteConfigContext} from '../../core/settings/site-config-context';\n\ninterface CrupdateTagFormProps {\n onSubmit: (values: Partial) => void;\n formId: string;\n form: UseFormReturn>;\n}\nexport function CrupdateTagForm({\n form,\n onSubmit,\n formId,\n}: CrupdateTagFormProps) {\n const {\n tags: {types},\n } = useContext(SiteConfigContext);\n const watchedType = form.watch('type');\n const isSystem = !!types.find(t => t.name === watchedType && t.system);\n\n return (\n
\n }\n description={}\n className=\"mb-20\"\n required\n autoFocus\n />\n }\n description={}\n className=\"mb-20\"\n />\n }\n name=\"type\"\n selectionMode=\"single\"\n disabled={isSystem}\n >\n {types\n .filter(t => !t.system)\n .map(type => (\n \n \n \n ))}\n \n \n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {toast} from '@common/ui/toast/toast';\nimport {message} from '@common/i18n/message';\nimport {Tag} from '@common/tags/tag';\nimport {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';\nimport {onFormQueryError} from '@common/errors/on-form-query-error';\nimport {UseFormReturn} from 'react-hook-form';\nimport {slugifyString} from '@common/utils/string/slugify-string';\n\ninterface Response extends BackendResponse {\n tag: Tag;\n}\n\ninterface Payload extends Partial {}\n\nexport function useCreateNewTag(form: UseFormReturn) {\n const {trans} = useTrans();\n return useMutation({\n mutationFn: (props: Payload) => createNewTag(props),\n onSuccess: () => {\n toast(trans(message('Tag created')));\n queryClient.invalidateQueries({queryKey: DatatableDataQueryKey('tags')});\n },\n onError: err => onFormQueryError(err, form),\n });\n}\n\nfunction createNewTag(payload: Payload): Promise {\n payload.name = slugifyString(payload.name!);\n return apiClient.post('tags', payload).then(r => r.data);\n}\n","import {Dialog} from '../../ui/overlays/dialog/dialog';\nimport {DialogHeader} from '../../ui/overlays/dialog/dialog-header';\nimport {Trans} from '../../i18n/trans';\nimport {DialogBody} from '../../ui/overlays/dialog/dialog-body';\nimport {CrupdateTagForm} from './crupdate-tag-form';\nimport {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';\nimport {Button} from '../../ui/buttons/button';\nimport {useDialogContext} from '../../ui/overlays/dialog/dialog-context';\nimport {useCreateNewTag} from './requests/use-create-new-tag';\nimport {useContext} from 'react';\nimport {SiteConfigContext} from '../../core/settings/site-config-context';\nimport {useForm} from 'react-hook-form';\nimport {Tag} from '../../tags/tag';\n\nexport function CreateTagDialog() {\n const {close, formId} = useDialogContext();\n const {\n tags: {types},\n } = useContext(SiteConfigContext);\n const form = useForm>({\n defaultValues: {\n type: types[0].name,\n },\n });\n const createNewTag = useCreateNewTag(form);\n\n return (\n \n \n \n \n \n {\n createNewTag.mutate(values, {\n onSuccess: () => {\n close();\n },\n });\n }}\n />\n \n \n {\n close();\n }}\n >\n \n \n \n \n \n \n \n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {toast} from '@common/ui/toast/toast';\nimport {message} from '@common/i18n/message';\nimport {Tag} from '@common/tags/tag';\nimport {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';\nimport {onFormQueryError} from '@common/errors/on-form-query-error';\nimport {UseFormReturn} from 'react-hook-form';\nimport {slugifyString} from '@common/utils/string/slugify-string';\n\ninterface Response extends BackendResponse {\n tag: Tag;\n}\n\nexport interface UpdateTagPayload extends Partial {\n id: number;\n}\n\nexport function useUpdateTag(form: UseFormReturn) {\n const {trans} = useTrans();\n return useMutation({\n mutationFn: (props: UpdateTagPayload) => updateTag(props),\n onSuccess: () => {\n toast(trans(message('Tag updated')));\n queryClient.invalidateQueries({queryKey: DatatableDataQueryKey('tags')});\n },\n onError: err => onFormQueryError(err, form),\n });\n}\n\nfunction updateTag({id, ...payload}: UpdateTagPayload): Promise {\n if (payload.name) {\n payload.name = slugifyString(payload.name!);\n }\n return apiClient.put(`tags/${id}`, payload).then(r => r.data);\n}\n","import {Dialog} from '../../ui/overlays/dialog/dialog';\nimport {DialogHeader} from '../../ui/overlays/dialog/dialog-header';\nimport {Trans} from '../../i18n/trans';\nimport {DialogBody} from '../../ui/overlays/dialog/dialog-body';\nimport {CrupdateTagForm} from './crupdate-tag-form';\nimport {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';\nimport {Button} from '../../ui/buttons/button';\nimport {useDialogContext} from '../../ui/overlays/dialog/dialog-context';\nimport {useForm} from 'react-hook-form';\nimport {Tag} from '../../tags/tag';\nimport {UpdateTagPayload, useUpdateTag} from './requests/use-update-tag';\n\ninterface UpdateTagDialogProps {\n tag: Tag;\n}\nexport function UpdateTagDialog({tag}: UpdateTagDialogProps) {\n const {close, formId} = useDialogContext();\n const form = useForm({\n defaultValues: {\n id: tag.id,\n name: tag.name,\n display_name: tag.display_name,\n type: tag.type,\n },\n });\n const updateTag = useUpdateTag(form);\n\n return (\n \n \n \n \n \n {\n updateTag.mutate(values as UpdateTagPayload, {\n onSuccess: () => {\n close();\n },\n });\n }}\n />\n \n \n {\n close();\n }}\n >\n \n \n \n \n \n \n \n );\n}\n","import React, {useContext, useMemo} from 'react';\nimport {DataTablePage} from '../../datatable/page/data-table-page';\nimport {IconButton} from '../../ui/buttons/icon-button';\nimport {EditIcon} from '../../icons/material/Edit';\nimport {FormattedDate} from '../../i18n/formatted-date';\nimport {ColumnConfig} from '../../datatable/column-config';\nimport {Trans} from '../../i18n/trans';\nimport {DeleteSelectedItemsAction} from '../../datatable/page/delete-selected-items-action';\nimport {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';\nimport {Tag} from '../../tags/tag';\nimport {SiteConfigContext} from '../../core/settings/site-config-context';\nimport {TagIndexPageFilters} from './tag-index-page-filters';\nimport softwareEngineerSvg from './software-engineer.svg';\nimport {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';\nimport {CreateTagDialog} from './create-tag-dialog';\nimport {UpdateTagDialog} from './update-tag-dialog';\nimport {DataTableAddItemButton} from '../../datatable/data-table-add-item-button';\n\nconst columnConfig: ColumnConfig[] = [\n {\n key: 'name',\n allowsSorting: true,\n visibleInMode: 'all',\n width: 'flex-3 min-w-200',\n header: () => ,\n body: tag => tag.name,\n },\n {\n key: 'type',\n allowsSorting: true,\n header: () => ,\n body: tag => tag.type,\n },\n {\n key: 'display_name',\n allowsSorting: true,\n header: () => ,\n body: tag => tag.display_name,\n },\n {\n key: 'updated_at',\n allowsSorting: true,\n width: 'w-100',\n header: () => ,\n body: tag => ,\n },\n {\n key: 'actions',\n header: () => ,\n hideHeader: true,\n align: 'end',\n width: 'w-42 flex-shrink-0',\n visibleInMode: 'all',\n body: tag => {\n return (\n \n \n \n \n \n \n );\n },\n },\n];\n\nexport function TagIndexPage() {\n const {tags} = useContext(SiteConfigContext);\n const filters = useMemo(() => {\n return TagIndexPageFilters(tags.types);\n }, [tags.types]);\n\n return (\n }\n columns={columnConfig}\n filters={filters}\n actions={}\n selectedActions={}\n emptyStateMessage={\n }\n filteringTitle={}\n />\n }\n />\n );\n}\n\nfunction Actions() {\n return (\n <>\n \n \n \n \n \n \n \n );\n}\n","import {Fragment, memo} from 'react';\nimport {prettyBytes} from './utils/pretty-bytes';\n\ninterface FormattedBytesProps {\n bytes?: number;\n}\nexport const FormattedBytes = memo(({bytes}: FormattedBytesProps) => {\n return {prettyBytes(bytes)};\n});\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const VisibilityIcon = createSvgIcon(\n \n, 'VisibilityOutlined');\n","export default \"__VITE_ASSET__31048831__\"","import React, {useContext, useMemo} from 'react';\nimport {FileEntry} from '../file-entry';\nimport {useSettings} from '../../core/settings/use-settings';\nimport {isAbsoluteUrl} from '@common/utils/urls/is-absolute-url';\n\nexport const FileEntryUrlsContext = React.createContext<\n Record\n>(null!);\n\nexport function useFileEntryUrls(\n entry?: FileEntry,\n options?: {thumbnail?: boolean; downloadHashes?: string[]},\n): {previewUrl?: string; downloadUrl?: string} {\n const {base_url} = useSettings();\n const urlSearchParams = useContext(FileEntryUrlsContext);\n\n return useMemo(() => {\n if (!entry) {\n return {};\n }\n\n let previewUrl: string | undefined;\n if (entry.url) {\n previewUrl = isAbsoluteUrl(entry.url)\n ? entry.url\n : `${base_url}/${entry.url}`;\n }\n\n const urls = {\n previewUrl,\n downloadUrl: `${base_url}/api/v1/file-entries/download/${\n options?.downloadHashes || entry.hash\n }`,\n };\n\n if (urlSearchParams) {\n // preview url\n if (urls.previewUrl) {\n urls.previewUrl = addParams(\n urls.previewUrl,\n {...urlSearchParams, thumbnail: options?.thumbnail ? 'true' : ''},\n base_url,\n );\n }\n\n // download url\n urls.downloadUrl = addParams(urls.downloadUrl, urlSearchParams, base_url);\n }\n\n return urls;\n }, [\n base_url,\n entry,\n options?.downloadHashes,\n options?.thumbnail,\n urlSearchParams,\n ]);\n}\n\nfunction addParams(urlString: string, params: object, baseUrl: string): string {\n const url = new URL(urlString, baseUrl);\n Object.entries(params).forEach(([key, value]) => {\n url.searchParams.append(key, value as string);\n });\n return url.toString();\n}\n","import React from 'react';\nimport {FileEntry} from '../file-entry';\n\nexport interface FilePreviewContextValue {\n entries: FileEntry[];\n activeIndex: number;\n}\n\nexport const FilePreviewContext = React.createContext(\n null!\n);\n","import {ReactNode, useContext} from 'react';\nimport clsx from 'clsx';\nimport {Button} from '../../../ui/buttons/button';\nimport {downloadFileFromUrl} from '../../utils/download-file-from-url';\nimport {FilePreviewContext} from '../file-preview-context';\nimport {Trans} from '../../../i18n/trans';\nimport {FilePreviewProps} from './file-preview-props';\nimport {useFileEntryUrls} from '../../hooks/file-entry-urls';\n\ninterface Props extends FilePreviewProps {\n message?: ReactNode;\n}\nexport function DefaultFilePreview({message, className, allowDownload}: Props) {\n const {entries, activeIndex} = useContext(FilePreviewContext);\n const activeEntry = entries[activeIndex];\n const content = message || ;\n const {downloadUrl} = useFileEntryUrls(activeEntry);\n return (\n \n
{content}
\n {allowDownload && (\n
\n {\n if (downloadUrl) {\n downloadFileFromUrl(downloadUrl);\n }\n }}\n >\n \n \n
\n )}\n \n );\n}\n","import clsx from 'clsx';\nimport {useFileEntryUrls} from '../../hooks/file-entry-urls';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {FilePreviewProps} from './file-preview-props';\nimport {DefaultFilePreview} from './default-file-preview';\n\nexport function ImageFilePreview(props: FilePreviewProps) {\n const {entry, className} = props;\n const {trans} = useTrans();\n const {previewUrl} = useFileEntryUrls(entry);\n\n if (!previewUrl) {\n return ;\n }\n\n return (\n \n );\n}\n","import {useEffect, useState} from 'react';\nimport clsx from 'clsx';\nimport {FilePreviewProps} from './file-preview-props';\nimport {DefaultFilePreview} from './default-file-preview';\nimport {ProgressCircle} from '@common/ui/progress/progress-circle';\nimport {useFileEntryUrls} from '@common/uploads/hooks/file-entry-urls';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {Trans} from '@common/i18n/trans';\nimport {apiClient} from '@common/http/query-client';\n\nconst FIVE_MB = 5242880;\n\nexport function TextFilePreview(props: FilePreviewProps) {\n const {entry, className} = props;\n const {trans} = useTrans();\n const [tooLarge, setTooLarge] = useState(false);\n const [isLoading, setIsLoading] = useState(true);\n const [isFailed, setIsFailed] = useState(false);\n const [contents, setContents] = useState(null);\n const {previewUrl} = useFileEntryUrls(entry);\n\n useEffect(() => {\n if (!entry) return;\n if (!previewUrl) {\n setIsFailed(true);\n } else if (entry.file_size! >= FIVE_MB) {\n setTooLarge(true);\n setIsLoading(false);\n } else {\n getFileContents(previewUrl)\n .then(response => {\n setContents(response.data);\n })\n .catch(() => {\n setIsFailed(true);\n })\n .finally(() => {\n setIsLoading(false);\n });\n }\n }, [entry, previewUrl]);\n\n if (isLoading) {\n return (\n \n );\n }\n\n if (tooLarge) {\n return (\n }\n />\n );\n }\n\n if (isFailed) {\n return (\n }\n />\n );\n }\n\n return (\n \n
{`${contents}`}
\n \n );\n}\n\nfunction getFileContents(src: string) {\n return apiClient.get(src, {\n responseType: 'text',\n // required for s3 presigned url to work\n withCredentials: false,\n headers: {\n Accept: 'text/plain',\n },\n });\n}\n","import {useEffect, useRef, useState} from 'react';\nimport {FilePreviewProps} from './file-preview-props';\nimport {DefaultFilePreview} from './default-file-preview';\nimport {useFileEntryUrls} from '../../hooks/file-entry-urls';\n\nexport function VideoFilePreview(props: FilePreviewProps) {\n const {entry, className} = props;\n const {previewUrl} = useFileEntryUrls(entry);\n const ref = useRef(null);\n const [mediaInvalid, setMediaInvalid] = useState(false);\n\n useEffect(() => {\n setMediaInvalid(!ref.current?.canPlayType(entry.mime));\n }, [entry]);\n\n if (mediaInvalid || !previewUrl) {\n return ;\n }\n\n return (\n \n {\n setMediaInvalid(true);\n }}\n />\n \n );\n}\n","import {FilePreviewProps} from './file-preview-props';\nimport {DefaultFilePreview} from './default-file-preview';\nimport {useFileEntryUrls} from '../../hooks/file-entry-urls';\nimport {useEffect, useRef, useState} from 'react';\n\nexport function AudioFilePreview(props: FilePreviewProps) {\n const {entry, className} = props;\n const {previewUrl} = useFileEntryUrls(entry);\n const ref = useRef(null);\n const [mediaInvalid, setMediaInvalid] = useState(false);\n\n useEffect(() => {\n setMediaInvalid(!ref.current?.canPlayType(entry.mime));\n }, [entry]);\n\n if (mediaInvalid || !previewUrl) {\n return ;\n }\n\n return (\n \n {\n setMediaInvalid(true);\n }}\n />\n \n );\n}\n","import clsx from 'clsx';\nimport {FilePreviewProps} from './file-preview-props';\nimport {useFileEntryUrls} from '../../hooks/file-entry-urls';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {DefaultFilePreview} from './default-file-preview';\n\nexport function PdfFilePreview(props: FilePreviewProps) {\n const {entry, className} = props;\n const {trans} = useTrans();\n const {previewUrl} = useFileEntryUrls(entry);\n\n if (!previewUrl) {\n return ;\n }\n\n return (\n \n );\n}\n","import clsx from 'clsx';\nimport {useEffect, useRef, useState} from 'react';\nimport {FilePreviewProps} from './file-preview-props';\nimport {DefaultFilePreview} from './default-file-preview';\nimport {ProgressCircle} from '../../../ui/progress/progress-circle';\nimport {FileEntry} from '../../file-entry';\nimport {useFileEntryUrls} from '../../hooks/file-entry-urls';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {apiClient} from '../../../http/query-client';\n\nexport function WordDocumentFilePreview(props: FilePreviewProps) {\n const {entry, className} = props;\n const {trans} = useTrans();\n const ref = useRef(null);\n const [showDefault, setShowDefault] = useState(false);\n const timeoutId = useRef();\n const [isLoading, setIsLoading] = useState(false);\n const {previewUrl} = useFileEntryUrls(entry);\n\n useEffect(() => {\n // Google Docs viewer only supports files up to 25MB\n if (!previewUrl) {\n setShowDefault(true);\n } else if (entry.file_size && entry.file_size > 25000000) {\n setShowDefault(true);\n } else if (ref.current) {\n ref.current.onload = () => {\n clearTimeout(timeoutId.current);\n setIsLoading(false);\n };\n\n buildPreviewUrl(previewUrl, entry).then(url => {\n if (ref.current) {\n ref.current.src = url;\n }\n });\n\n // if preview iframe is not loaded\n // after 5 seconds, bail and show default preview\n timeoutId.current = setTimeout(() => {\n setShowDefault(true);\n }, 5000);\n }\n }, [entry, previewUrl]);\n\n if (showDefault) {\n return ;\n }\n\n return (\n
\n {isLoading && }\n \n
\n );\n}\n\nasync function buildPreviewUrl(\n urlString: string,\n entry: FileEntry\n): Promise {\n const url = new URL(urlString);\n // if we're not trying to preview shareable link we will need to generate\n // preview token, otherwise it won't be publicly accessible\n if (!url.searchParams.has('shareable_link')) {\n const {data} = await apiClient.post(\n `file-entries/${entry.id}/add-preview-token`\n );\n url.searchParams.append('preview_token', data.preview_token);\n }\n\n return buildOfficeLivePreviewUrl(url);\n}\n\nfunction buildOfficeLivePreviewUrl(url: URL) {\n // https://docs.google.com/gview?embedded=true&url=\n return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(\n url.toString()\n )}`;\n}\n","import {ImageFilePreview} from './file-preview/image-file-preview';\nimport {FileEntry} from '../file-entry';\nimport {DefaultFilePreview} from './file-preview/default-file-preview';\nimport {TextFilePreview} from './file-preview/text-file-preview';\nimport {VideoFilePreview} from './file-preview/video-file-preview';\nimport {AudioFilePreview} from './file-preview/audio-file-preview';\nimport {PdfFilePreview} from './file-preview/pdf-file-preview';\nimport {WordDocumentFilePreview} from './file-preview/word-document-file-preview';\n\nexport const AvailablePreviews = {\n text: TextFilePreview,\n video: VideoFilePreview,\n audio: AudioFilePreview,\n image: ImageFilePreview,\n pdf: PdfFilePreview,\n spreadsheet: WordDocumentFilePreview,\n powerPoint: WordDocumentFilePreview,\n word: WordDocumentFilePreview,\n 'text/rtf': DefaultFilePreview,\n} as const;\n\nexport function getPreviewForEntry(entry: FileEntry) {\n const mime = entry?.mime as keyof typeof AvailablePreviews;\n const type = entry?.type as keyof typeof AvailablePreviews;\n return (\n AvailablePreviews[mime] || AvailablePreviews[type] || DefaultFilePreview\n );\n}\n","import {createSvgIcon} from '../../../icons/create-svg-icon';\n\nexport const DefaultFileIcon = createSvgIcon(\n \n \n \n);\n","import {createSvgIcon} from '../../../icons/create-svg-icon';\n\nexport const AudioFileIcon = createSvgIcon(\n \n \n \n);\n","import {createSvgIcon} from '../../../icons/create-svg-icon';\n\nexport const VideoFileIcon = createSvgIcon(\n \n \n \n);\n","import {createSvgIcon} from '../../../icons/create-svg-icon';\n\nexport const TextFileIcon = createSvgIcon(\n \n \n \n);\n","import {createSvgIcon} from '../../../icons/create-svg-icon';\n\nexport const PdfFileIcon = createSvgIcon(\n \n \n \n);\n","import {createSvgIcon} from '../../../icons/create-svg-icon';\n\nexport const ArchiveFileIcon = createSvgIcon(\n \n \n \n);\n","import {createSvgIcon} from '../../../icons/create-svg-icon';\n\nexport const FolderFileIcon = createSvgIcon(\n \n \n \n);\n","import {createSvgIcon} from '../../../icons/create-svg-icon';\n\nexport const ImageFileIcon = createSvgIcon(\n \n \n \n);\n","import {createSvgIcon} from '../../../icons/create-svg-icon';\n\nexport const PowerPointFileIcon = createSvgIcon(\n \n \n \n);\n","import {createSvgIcon} from '../../../icons/create-svg-icon';\n\nexport const WordFileIcon = createSvgIcon(\n \n \n \n);\n","import {createSvgIcon} from '../../../icons/create-svg-icon';\n\nexport const SpreadsheetFileIcon = createSvgIcon(\n \n \n \n);\n","import {createSvgIcon} from '../../../icons/create-svg-icon';\n\nexport const SharedFolderFileIcon = createSvgIcon(\n \n \n \n);\n","import clsx from 'clsx';\nimport {DefaultFileIcon} from './icons/default-file-icon';\nimport {AudioFileIcon} from './icons/audio-file-icon';\nimport {VideoFileIcon} from './icons/video-file-icon';\nimport {TextFileIcon} from './icons/text-file-icon';\nimport {PdfFileIcon} from './icons/pdf-file-icon';\nimport {ArchiveFileIcon} from './icons/archive-file-icon';\nimport {FolderFileIcon} from './icons/folder-file-icon';\nimport {ImageFileIcon} from './icons/image-file-icon';\nimport {PowerPointFileIcon} from './icons/power-point-file-icon';\nimport {WordFileIcon} from './icons/word-file-icon';\nimport {SpreadsheetFileIcon} from './icons/spreadsheet-file-icon';\nimport {SharedFolderFileIcon} from './icons/shared-folder-file-icon';\nimport {IconSize} from '@common/icons/svg-icon';\n\ninterface Props {\n type?: string;\n mime?: string | null;\n className?: string;\n size?: IconSize;\n}\nexport function FileTypeIcon({type, mime, className, size}: Props) {\n if (!type && mime) {\n type = mime.split('/')[0];\n }\n // @ts-ignore\n const Icon = FileTypeIcons[type] || FileTypeIcons.default;\n return (\n \n );\n}\n\nconst FileTypeIcons = {\n default: DefaultFileIcon,\n audio: AudioFileIcon,\n video: VideoFileIcon,\n text: TextFileIcon,\n pdf: PdfFileIcon,\n archive: ArchiveFileIcon,\n folder: FolderFileIcon,\n sharedFolder: SharedFolderFileIcon,\n image: ImageFileIcon,\n powerPoint: PowerPointFileIcon,\n word: WordFileIcon,\n spreadsheet: SpreadsheetFileIcon,\n};\n","import clsx from 'clsx';\nimport {FileTypeIcon} from './file-type-icon';\nimport {useFileEntryUrls} from '../hooks/file-entry-urls';\nimport {useTrans} from '../../i18n/use-trans';\nimport {FileEntry} from '../file-entry';\n\nconst TwoMB = 2 * 1024 * 1024;\n\ninterface Props {\n file: FileEntry;\n className?: string;\n iconClassName?: string;\n showImage?: boolean;\n}\nexport function FileThumbnail({\n file,\n className,\n iconClassName,\n showImage = true,\n}: Props) {\n const {trans} = useTrans();\n const {previewUrl} = useFileEntryUrls(file, {thumbnail: true});\n\n // don't show images for files larger than 2MB, if thumbnail was not generated to avoid ui lag\n if (file.file_size && file.file_size > TwoMB && !file.thumbnail) {\n showImage = false;\n }\n\n if (showImage && file.type === 'image' && previewUrl) {\n const alt = trans({\n message: ':fileName thumbnail',\n values: {fileName: file.name},\n });\n return (\n \n );\n }\n return ;\n}\n","import {AnimatePresence, m} from 'framer-motion';\nimport {Fragment, ReactNode, useContext, useMemo} from 'react';\nimport clsx from 'clsx';\nimport {getPreviewForEntry} from './available-previews';\nimport {FileEntry} from '../file-entry';\nimport {FilePreviewContext} from './file-preview-context';\nimport {IconButton} from '../../ui/buttons/icon-button';\nimport {ChevronLeftIcon} from '../../icons/material/ChevronLeft';\nimport {ChevronRightIcon} from '../../icons/material/ChevronRight';\nimport {FileDownloadIcon} from '../../icons/material/FileDownload';\nimport {downloadFileFromUrl} from '../utils/download-file-from-url';\nimport {useFileEntryUrls} from '../hooks/file-entry-urls';\nimport {Trans} from '../../i18n/trans';\nimport {Button} from '../../ui/buttons/button';\nimport {CloseIcon} from '../../icons/material/Close';\nimport {FileThumbnail} from '../file-type-icon/file-thumbnail';\nimport {useMediaQuery} from '../../utils/hooks/use-media-query';\nimport {KeyboardArrowLeftIcon} from '../../icons/material/KeyboardArrowLeft';\nimport {KeyboardArrowRightIcon} from '../../icons/material/KeyboardArrowRight';\nimport {useControlledState} from '@react-stately/utils';\nimport {opacityAnimation} from '../../ui/animation/opacity-animation';\n\nexport interface FilePreviewContainerProps {\n entries: FileEntry[];\n activeIndex?: number;\n defaultActiveIndex?: number;\n onActiveIndexChange?: (index: number) => void;\n onClose?: () => void;\n showHeader?: boolean;\n headerActionsLeft?: ReactNode;\n className?: string;\n allowDownload?: boolean;\n}\nexport function FilePreviewContainer({\n entries,\n onClose,\n showHeader = true,\n className,\n headerActionsLeft,\n allowDownload = true,\n ...props\n}: FilePreviewContainerProps) {\n const isMobile = useMediaQuery('(max-width: 1024px)');\n\n const [activeIndex, setActiveIndex] = useControlledState(\n props.activeIndex,\n props.defaultActiveIndex || 0,\n props.onActiveIndexChange\n );\n\n const activeEntry = entries[activeIndex];\n const contextValue = useMemo(() => {\n return {entries, activeIndex};\n }, [entries, activeIndex]);\n const Preview = getPreviewForEntry(activeEntry);\n\n if (!activeEntry) {\n onClose?.();\n return null;\n }\n\n const canOpenNext = entries.length - 1 > activeIndex;\n const openNext = () => {\n setActiveIndex(activeIndex + 1);\n };\n const canOpenPrevious = activeIndex > 0;\n const openPrevious = () => {\n setActiveIndex(activeIndex - 1);\n };\n\n return (\n \n {showHeader && (\n \n )}\n
\n {isMobile && (\n \n \n \n )}\n \n \n \n \n \n {isMobile && (\n \n \n \n )}\n
\n
\n );\n}\n\ninterface HeaderProps {\n onNext?: () => void;\n onPrevious?: () => void;\n onClose?: () => void;\n isMobile: boolean | null;\n actionsLeft?: ReactNode;\n allowDownload?: boolean;\n}\nfunction Header({\n onNext,\n onPrevious,\n onClose,\n isMobile,\n actionsLeft,\n allowDownload,\n}: HeaderProps) {\n const {entries, activeIndex} = useContext(FilePreviewContext);\n const activeEntry = entries[activeIndex];\n const {downloadUrl} = useFileEntryUrls(activeEntry);\n\n const desktopDownloadButton = (\n }\n variant=\"text\"\n onClick={() => {\n if (downloadUrl) {\n downloadFileFromUrl(downloadUrl);\n }\n }}\n >\n \n \n );\n\n const mobileDownloadButton = (\n {\n if (downloadUrl) {\n downloadFileFromUrl(downloadUrl);\n }\n }}\n >\n \n \n );\n\n const downloadButton = isMobile\n ? mobileDownloadButton\n : desktopDownloadButton;\n\n return (\n
\n
\n {actionsLeft}\n {allowDownload ? downloadButton : undefined}\n
\n
\n \n
\n {activeEntry.name}\n
\n
\n
\n {!isMobile && (\n \n \n \n \n
{activeIndex + 1}
\n
/
\n
{entries.length}
\n \n \n \n
\n \n )}\n \n \n \n
\n
\n );\n}\n","import {\n FilePreviewContainer,\n FilePreviewContainerProps,\n} from './file-preview-container';\nimport {useDialogContext} from '../../ui/overlays/dialog/dialog-context';\nimport {Dialog} from '../../ui/overlays/dialog/dialog';\n\ninterface Props extends Omit {}\nexport function FilePreviewDialog(props: Props) {\n return (\n \n \n \n );\n}\n\nfunction Content(props: Props) {\n const {close} = useDialogContext();\n return ;\n}\n","import {\n BackendFilter,\n FilterControlType,\n FilterOperator,\n FilterSelectControl,\n} from '../../datatable/filters/backend-filter';\nimport {message} from '../../i18n/message';\nimport {USER_MODEL} from '../../auth/user';\nimport {\n createdAtFilter,\n updatedAtFilter,\n} from '@common/datatable/filters/timestamp-filters';\n\nexport const FILE_ENTRY_TYPE_FILTER: BackendFilter = {\n key: 'type',\n label: message('Type'),\n description: message('Type of the file'),\n defaultOperator: FilterOperator.eq,\n control: {\n type: FilterControlType.Select,\n defaultValue: '05',\n options: [\n {key: '02', label: message('Text'), value: 'text'},\n {\n key: '03',\n label: message('Audio'),\n value: 'audio',\n },\n {\n key: '04',\n label: message('Video'),\n value: 'video',\n },\n {\n key: '05',\n label: message('Image'),\n value: 'image',\n },\n {key: '06', label: message('PDF'), value: 'pdf'},\n {\n key: '07',\n label: message('Spreadsheet'),\n value: 'spreadsheet',\n },\n {\n key: '08',\n label: message('Word Document'),\n value: 'word',\n },\n {\n key: '09',\n label: message('Photoshop'),\n value: 'photoshop',\n },\n {\n key: '10',\n label: message('Archive'),\n value: 'archive',\n },\n {\n key: '11',\n label: message('Folder'),\n value: 'folder',\n },\n ],\n },\n};\n\nexport const FILE_ENTRY_INDEX_FILTERS: BackendFilter[] = [\n FILE_ENTRY_TYPE_FILTER,\n {\n key: 'public',\n label: message('Visibility'),\n description: message('Whether file is publicly accessible'),\n defaultOperator: FilterOperator.eq,\n control: {\n type: FilterControlType.Select,\n defaultValue: '01',\n options: [\n {key: '01', label: message('Private'), value: false},\n {key: '02', label: message('Public'), value: true},\n ],\n },\n },\n createdAtFilter({\n description: message('Date file was uploaded'),\n }),\n updatedAtFilter({\n description: message('Date file was last changed'),\n }),\n {\n key: 'owner_id',\n label: message('Uploader'),\n description: message('User that this file was uploaded by'),\n defaultOperator: FilterOperator.eq,\n control: {\n type: FilterControlType.SelectModel,\n model: USER_MODEL,\n },\n },\n];\n","import React, {Fragment} from 'react';\nimport {DataTablePage} from '../../datatable/page/data-table-page';\nimport {IconButton} from '../../ui/buttons/icon-button';\nimport {FormattedDate} from '../../i18n/formatted-date';\nimport {ColumnConfig} from '../../datatable/column-config';\nimport {Trans} from '../../i18n/trans';\nimport {DeleteSelectedItemsAction} from '../../datatable/page/delete-selected-items-action';\nimport {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';\nimport {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';\nimport {FileEntry} from '../../uploads/file-entry';\nimport {NameWithAvatar} from '../../datatable/column-templates/name-with-avatar';\nimport {User} from '../../auth/user';\nimport {CheckIcon} from '../../icons/material/Check';\nimport {CloseIcon} from '../../icons/material/Close';\nimport {FormattedBytes} from '../../uploads/formatted-bytes';\nimport {VisibilityIcon} from '../../icons/material/Visibility';\nimport uploadSvg from './upload.svg';\nimport {FilePreviewDialog} from '../../uploads/preview/file-preview-dialog';\nimport {FILE_ENTRY_INDEX_FILTERS} from './file-entry-index-filters';\nimport {FileTypeIcon} from '../../uploads/file-type-icon/file-type-icon';\n\nconst columnConfig: ColumnConfig[] = [\n {\n key: 'name',\n allowsSorting: true,\n visibleInMode: 'all',\n width: 'flex-3 min-w-200',\n header: () => ,\n body: entry => (\n \n
{entry.name}
\n
\n {entry.file_name}\n
\n
\n ),\n },\n {\n key: 'owner_id',\n allowsSorting: true,\n width: 'flex-3 min-w-200',\n header: () => ,\n body: entry => {\n const user = entry.users?.[0] as User;\n if (!user) return null;\n return (\n \n );\n },\n },\n {\n key: 'type',\n width: 'w-100 flex-shrink-0',\n allowsSorting: true,\n header: () => ,\n body: entry => (\n
\n \n
{entry.type}
\n
\n ),\n },\n {\n key: 'public',\n allowsSorting: true,\n width: 'w-60 flex-shrink-0',\n header: () => ,\n body: entry =>\n entry.public ? (\n \n ) : (\n \n ),\n },\n {\n key: 'file_size',\n allowsSorting: true,\n maxWidth: 'max-w-100',\n header: () => ,\n body: entry => ,\n },\n {\n key: 'updated_at',\n allowsSorting: true,\n width: 'w-100',\n header: () => ,\n body: entry => ,\n },\n {\n key: 'actions',\n header: () => ,\n hideHeader: true,\n align: 'end',\n width: 'w-42 flex-shrink-0',\n visibleInMode: 'all',\n body: entry => {\n return (\n \n \n \n \n \n \n );\n },\n },\n];\n\nexport function FileEntryIndexPage() {\n return (\n }\n columns={columnConfig}\n filters={FILE_ENTRY_INDEX_FILTERS}\n selectedActions={}\n emptyStateMessage={\n }\n filteringTitle={}\n />\n }\n />\n );\n}\n","import {\n BackendFilter,\n FilterControlType,\n FilterOperator,\n} from '../../datatable/filters/backend-filter';\nimport {message} from '../../i18n/message';\nimport {\n createdAtFilter,\n timestampFilter,\n updatedAtFilter,\n} from '../../datatable/filters/timestamp-filters';\n\nexport const SubscriptionIndexPageFilters: BackendFilter[] = [\n {\n key: 'ends_at',\n label: message('Status'),\n description: message('Whether subscription is active or cancelled'),\n defaultOperator: FilterOperator.eq,\n control: {\n type: FilterControlType.Select,\n defaultValue: 'active',\n options: [\n {\n key: 'active',\n label: message('Active'),\n value: {value: null, operator: FilterOperator.eq},\n },\n {\n key: 'cancelled',\n label: message('Cancelled'),\n value: {value: null, operator: FilterOperator.ne},\n },\n ],\n },\n },\n {\n control: {\n type: FilterControlType.Select,\n defaultValue: 'stripe',\n options: [\n {\n key: 'stripe',\n label: message('Stripe'),\n value: 'stripe',\n },\n {\n key: 'paypal',\n label: message('PayPal'),\n value: 'paypal',\n },\n {\n key: 'none',\n label: message('None'),\n value: 'none',\n },\n ],\n },\n key: 'gateway_name',\n label: message('Gateway'),\n description: message(\n 'With which payment provider was subscription created'\n ),\n defaultOperator: FilterOperator.eq,\n },\n timestampFilter({\n key: 'renews_at',\n label: message('Renew date'),\n description: message('Date subscription will renew'),\n }),\n createdAtFilter({\n description: message('Date subscription was created'),\n }),\n updatedAtFilter({\n description: message('Date subscription was last updated'),\n }),\n];\n","export default \"__VITE_ASSET__2e46d67b__\"","import {useMutation} from '@tanstack/react-query';\nimport {apiClient, queryClient} from '../../../http/query-client';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {BackendResponse} from '../../../http/backend-response/backend-response';\nimport {toast} from '../../../ui/toast/toast';\nimport {message} from '../../../i18n/message';\nimport {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';\nimport {onFormQueryError} from '../../../errors/on-form-query-error';\nimport {UseFormReturn} from 'react-hook-form';\nimport {Subscription} from '../../../billing/subscription';\n\ninterface Response extends BackendResponse {\n subscription: Subscription;\n}\n\nexport interface UpdateSubscriptionPayload extends Partial {\n id: number;\n}\n\nexport function useUpdateSubscription(\n form: UseFormReturn,\n) {\n const {trans} = useTrans();\n return useMutation({\n mutationFn: (props: UpdateSubscriptionPayload) => updateSubscription(props),\n onSuccess: () => {\n toast(trans(message('Subscription updated')));\n queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey('billing/subscriptions'),\n });\n },\n onError: err => onFormQueryError(err, form),\n });\n}\n\nfunction updateSubscription({\n id,\n ...payload\n}: UpdateSubscriptionPayload): Promise {\n return apiClient\n .put(`billing/subscriptions/${id}`, payload)\n .then(r => r.data);\n}\n","import {UseFormReturn} from 'react-hook-form';\nimport {Form} from '../../ui/forms/form';\nimport {FormTextField} from '../../ui/forms/input-field/text-field/text-field';\nimport {FormSelect} from '../../ui/forms/select/select';\nimport {Trans} from '../../i18n/trans';\nimport {Item} from '../../ui/forms/listbox/item';\nimport {Subscription} from '../../billing/subscription';\nimport {FormDatePicker} from '../../ui/forms/input-field/date/date-picker/date-picker';\nimport {useProducts} from '../../billing/pricing-table/use-products';\nimport {FormattedPrice} from '../../i18n/formatted-price';\nimport {FormNormalizedModelField} from '../../ui/forms/normalized-model-field';\n\ninterface CrupdateSubscriptionForm {\n onSubmit: (values: Partial) => void;\n formId: string;\n form: UseFormReturn>;\n}\nexport function CrupdateSubscriptionForm({\n form,\n onSubmit,\n formId,\n}: CrupdateSubscriptionForm) {\n const query = useProducts();\n // @ts-ignore\n const watchedProductId = form.watch('product_id');\n const selectedProduct = query.data?.products.find(\n p => p.id === watchedProductId,\n );\n\n return (\n
\n }\n />\n }\n >\n {query.data?.products\n .filter(p => !p.free)\n .map(product => (\n \n \n \n ))}\n \n {!selectedProduct?.free && (\n }\n >\n {selectedProduct?.prices.map(price => (\n \n \n \n ))}\n \n )}\n }\n className=\"mb-20\"\n />\n }\n description={\n \n }\n />\n }\n description={\n \n }\n />\n \n );\n}\n","import {Dialog} from '../../ui/overlays/dialog/dialog';\nimport {DialogHeader} from '../../ui/overlays/dialog/dialog-header';\nimport {Trans} from '../../i18n/trans';\nimport {DialogBody} from '../../ui/overlays/dialog/dialog-body';\nimport {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';\nimport {Button} from '../../ui/buttons/button';\nimport {useDialogContext} from '../../ui/overlays/dialog/dialog-context';\nimport {useForm} from 'react-hook-form';\nimport {Subscription} from '../../billing/subscription';\nimport {\n UpdateSubscriptionPayload,\n useUpdateSubscription,\n} from './requests/use-update-subscription';\nimport {CrupdateSubscriptionForm} from './crupdate-subscription-form';\n\ninterface UpdateSubscriptionDialogProps {\n subscription: Subscription;\n}\nexport function UpdateSubscriptionDialog({\n subscription,\n}: UpdateSubscriptionDialogProps) {\n const {close, formId} = useDialogContext();\n const form = useForm({\n defaultValues: {\n id: subscription.id,\n product_id: subscription.product_id,\n price_id: subscription.price_id,\n description: subscription.description,\n renews_at: subscription.renews_at,\n ends_at: subscription.ends_at,\n user_id: subscription.user_id,\n },\n });\n const updateSubscription = useUpdateSubscription(form);\n\n return (\n \n \n \n \n \n {\n updateSubscription.mutate(values as UpdateSubscriptionPayload, {\n onSuccess: () => {\n close();\n },\n });\n }}\n />\n \n \n {\n close();\n }}\n >\n \n \n \n \n \n \n \n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient, queryClient} from '../../../http/query-client';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {BackendResponse} from '../../../http/backend-response/backend-response';\nimport {toast} from '../../../ui/toast/toast';\nimport {message} from '../../../i18n/message';\nimport {Tag} from '../../../tags/tag';\nimport {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';\nimport {onFormQueryError} from '../../../errors/on-form-query-error';\nimport {UseFormReturn} from 'react-hook-form';\nimport {Subscription} from '../../../billing/subscription';\n\nconst endpoint = 'billing/subscriptions';\n\ninterface Response extends BackendResponse {\n tag: Tag;\n}\n\ninterface Payload extends Partial {}\n\nexport function useCreateSubscription(form: UseFormReturn) {\n const {trans} = useTrans();\n return useMutation({\n mutationFn: (props: Payload) => createNewSubscription(props),\n onSuccess: () => {\n toast(trans(message('Subscription created')));\n queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey(endpoint),\n });\n },\n onError: err => onFormQueryError(err, form),\n });\n}\n\nfunction createNewSubscription(payload: Payload): Promise {\n return apiClient.post(endpoint, payload).then(r => r.data);\n}\n","import {Dialog} from '../../ui/overlays/dialog/dialog';\nimport {DialogHeader} from '../../ui/overlays/dialog/dialog-header';\nimport {Trans} from '../../i18n/trans';\nimport {DialogBody} from '../../ui/overlays/dialog/dialog-body';\nimport {DialogFooter} from '../../ui/overlays/dialog/dialog-footer';\nimport {Button} from '../../ui/buttons/button';\nimport {useDialogContext} from '../../ui/overlays/dialog/dialog-context';\nimport {useForm} from 'react-hook-form';\nimport {useCreateSubscription} from './requests/use-create-subscription';\nimport {Subscription} from '../../billing/subscription';\nimport {CrupdateSubscriptionForm} from './crupdate-subscription-form';\n\nexport function CreateSubscriptionDialog() {\n const {close, formId} = useDialogContext();\n const form = useForm>({});\n const createSubscription = useCreateSubscription(form);\n\n return (\n \n \n \n \n \n {\n createSubscription.mutate(values, {\n onSuccess: () => {\n close();\n },\n });\n }}\n />\n \n \n {\n close();\n }}\n >\n \n \n \n \n \n \n \n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const PauseIcon = createSvgIcon(\n \n, 'PauseOutlined');\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const PlayArrowIcon = createSvgIcon(\n \n, 'PlayArrowOutlined');\n","import React, {Fragment} from 'react';\nimport {DataTablePage} from '../../datatable/page/data-table-page';\nimport {IconButton} from '../../ui/buttons/icon-button';\nimport {EditIcon} from '../../icons/material/Edit';\nimport {ColumnConfig} from '../../datatable/column-config';\nimport {Trans} from '../../i18n/trans';\nimport {DeleteSelectedItemsAction} from '../../datatable/page/delete-selected-items-action';\nimport {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';\nimport {SubscriptionIndexPageFilters} from './subscription-index-page-filters';\nimport {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';\nimport {DataTableAddItemButton} from '../../datatable/data-table-add-item-button';\nimport subscriptionsSvg from './subscriptions.svg';\nimport {NameWithAvatar} from '../../datatable/column-templates/name-with-avatar';\nimport {Subscription} from '../../billing/subscription';\nimport {CloseIcon} from '../../icons/material/Close';\nimport {FormattedDate} from '../../i18n/formatted-date';\nimport {UpdateSubscriptionDialog} from './update-subscription-dialog';\nimport {CreateSubscriptionDialog} from './create-subscription-dialog';\nimport {useCancelSubscription} from '../../billing/billing-page/requests/use-cancel-subscription';\nimport {PauseIcon} from '../../icons/material/Pause';\nimport {queryClient} from '../../http/query-client';\nimport {DatatableDataQueryKey} from '../../datatable/requests/paginated-resources';\nimport {Tooltip} from '../../ui/tooltip/tooltip';\nimport {useResumeSubscription} from '../../billing/billing-page/requests/use-resume-subscription';\nimport {PlayArrowIcon} from '../../icons/material/PlayArrow';\nimport {ConfirmationDialog} from '../../ui/overlays/dialog/confirmation-dialog';\nimport {Chip} from '../../ui/forms/input-field/chip-field/chip';\n\nconst endpoint = 'billing/subscriptions';\n\nconst columnConfig: ColumnConfig[] = [\n {\n key: 'user_id',\n allowsSorting: true,\n width: 'flex-3 min-w-200',\n visibleInMode: 'all',\n header: () => ,\n body: subscription =>\n subscription.user && (\n \n ),\n },\n {\n key: 'status',\n width: 'w-100 flex-shrink-0',\n header: () => ,\n body: subscription => (\n \n {subscription.gateway_status}\n \n ),\n },\n {\n key: 'product_id',\n allowsSorting: true,\n header: () => ,\n body: subscription => subscription.product?.name,\n },\n {\n key: 'gateway_name',\n allowsSorting: true,\n header: () => ,\n body: subscription => (\n {subscription.gateway_name}\n ),\n },\n {\n key: 'renews_at',\n allowsSorting: true,\n header: () => ,\n body: subscription => ,\n },\n {\n key: 'ends_at',\n allowsSorting: true,\n header: () => ,\n body: subscription => ,\n },\n {\n key: 'created_at',\n allowsSorting: true,\n header: () => ,\n body: subscription => ,\n },\n {\n key: 'actions',\n header: () => ,\n hideHeader: true,\n align: 'end',\n visibleInMode: 'all',\n width: 'w-[168px] flex-shrink-0',\n body: subscription => {\n return ;\n },\n },\n];\n\nexport function SubscriptionsIndexPage() {\n return (\n }\n columns={columnConfig}\n filters={SubscriptionIndexPageFilters}\n actions={}\n enableSelection={false}\n selectedActions={}\n queryParams={{with: 'product'}}\n emptyStateMessage={\n }\n filteringTitle={}\n />\n }\n />\n );\n}\n\nfunction PageActions() {\n return (\n <>\n \n \n \n \n \n \n \n );\n}\n\ninterface SubscriptionActionsProps {\n subscription: Subscription;\n}\nfunction SubscriptionActions({subscription}: SubscriptionActionsProps) {\n return (\n \n \n \n \n \n \n \n {subscription.cancelled && subscription.on_grace_period ? (\n \n ) : null}\n {subscription.active ? (\n \n ) : null}\n \n \n );\n}\n\nfunction SuspendSubscriptionButton({subscription}: SubscriptionActionsProps) {\n const cancelSubscription = useCancelSubscription();\n\n const handleSuspendSubscription = () => {\n cancelSubscription.mutate(\n {subscriptionId: subscription.id},\n {\n onSuccess: () => {\n queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey(endpoint),\n });\n },\n },\n );\n };\n\n return (\n {\n if (confirmed) {\n handleSuspendSubscription();\n }\n }}\n >\n }>\n \n \n \n \n }\n body={\n
\n \n
\n \n
\n
\n }\n confirm={}\n />\n \n );\n}\n\nfunction ResumeSubscriptionButton({subscription}: SubscriptionActionsProps) {\n const resumeSubscription = useResumeSubscription();\n const handleResumeSubscription = () => {\n resumeSubscription.mutate(\n {subscriptionId: subscription.id},\n {\n onSuccess: () => {\n queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey(endpoint),\n });\n },\n },\n );\n };\n\n return (\n {\n if (confirmed) {\n handleResumeSubscription();\n }\n }}\n >\n }>\n \n \n \n \n }\n body={\n
\n \n
\n \n
\n
\n }\n confirm={}\n />\n \n );\n}\n\nfunction CancelSubscriptionButton({subscription}: SubscriptionActionsProps) {\n const cancelSubscription = useCancelSubscription();\n\n const handleDeleteSubscription = () => {\n cancelSubscription.mutate(\n {subscriptionId: subscription.id, delete: true},\n {\n onSuccess: () => {\n queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey(endpoint),\n });\n },\n },\n );\n };\n\n return (\n {\n if (confirmed) {\n handleDeleteSubscription();\n }\n }}\n >\n }>\n \n \n \n \n }\n body={\n
\n \n
\n \n
\n
\n }\n confirm={}\n />\n \n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const SyncIcon = createSvgIcon(\n \n, 'SyncOutlined');\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient} from '../../../http/query-client';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {BackendResponse} from '../../../http/backend-response/backend-response';\nimport {toast} from '../../../ui/toast/toast';\nimport {message} from '../../../i18n/message';\nimport {showHttpErrorToast} from '../../../utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {}\n\nexport function useSyncProducts() {\n const {trans} = useTrans();\n return useMutation({\n mutationFn: () => syncPlans(),\n onSuccess: () => {\n toast(trans(message('Plans synced')));\n },\n onError: err => showHttpErrorToast(err, message('Could not sync plans')),\n });\n}\n\nfunction syncPlans(): Promise {\n return apiClient.post('billing/products/sync').then(r => r.data);\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient, queryClient} from '../../../http/query-client';\nimport {BackendResponse} from '../../../http/backend-response/backend-response';\nimport {toast} from '../../../ui/toast/toast';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {message} from '../../../i18n/message';\nimport {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';\nimport {showHttpErrorToast} from '../../../utils/http/show-http-error-toast';\n\nconst endpoint = (id: number) => `billing/products/${id}`;\n\ninterface Response extends BackendResponse {}\n\ninterface Payload {\n productId: number;\n}\n\nexport function useDeleteProduct() {\n const {trans} = useTrans();\n return useMutation({\n mutationFn: (payload: Payload) => updateProduct(payload),\n onSuccess: () => {\n toast(trans(message('Plan deleted')));\n queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey('billing/products'),\n });\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction updateProduct({productId}: Payload): Promise {\n return apiClient.delete(endpoint(productId)).then(r => r.data);\n}\n","import {\n BackendFilter,\n FilterControlType,\n FilterOperator,\n} from '../../datatable/filters/backend-filter';\nimport {message} from '../../i18n/message';\nimport {\n createdAtFilter,\n updatedAtFilter,\n} from '@common/datatable/filters/timestamp-filters';\n\nexport const PlansIndexPageFilters: BackendFilter[] = [\n {\n key: 'subscriptions',\n label: message('Subscriptions'),\n description: message('Whether plan has any active subscriptions'),\n defaultOperator: FilterOperator.eq,\n control: {\n type: FilterControlType.Select,\n defaultValue: '01',\n options: [\n {\n key: '01',\n label: message('Has active subscriptions'),\n value: {value: '*', operator: FilterOperator.has},\n },\n {\n key: '02',\n label: message('Does not have active subscriptions'),\n value: {value: '*', operator: FilterOperator.doesntHave},\n },\n ],\n },\n },\n createdAtFilter({\n description: message('Date plan was created'),\n }),\n updatedAtFilter({\n description: message('Date plan was last updated'),\n }),\n];\n","import React, {Fragment} from 'react';\nimport {DataTablePage} from '../../datatable/page/data-table-page';\nimport {IconButton} from '../../ui/buttons/icon-button';\nimport {EditIcon} from '../../icons/material/Edit';\nimport {FormattedDate} from '../../i18n/formatted-date';\nimport {ColumnConfig} from '../../datatable/column-config';\nimport {Trans} from '../../i18n/trans';\nimport {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';\nimport softwareEngineerSvg from './../tags/software-engineer.svg';\nimport {DataTableAddItemButton} from '../../datatable/data-table-add-item-button';\nimport {Product} from '../../billing/product';\nimport {NameWithAvatar} from '../../datatable/column-templates/name-with-avatar';\nimport {Link} from 'react-router-dom';\nimport {FormattedPrice} from '../../i18n/formatted-price';\nimport {SyncIcon} from '../../icons/material/Sync';\nimport {useSyncProducts} from './requests/use-sync-products';\nimport {Tooltip} from '../../ui/tooltip/tooltip';\nimport {useDeleteProduct} from './requests/use-delete-product';\nimport {DeleteIcon} from '../../icons/material/Delete';\nimport {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';\nimport {ConfirmationDialog} from '../../ui/overlays/dialog/confirmation-dialog';\nimport {useNavigate} from '../../utils/hooks/use-navigate';\nimport {PlansIndexPageFilters} from './plans-index-page-filters';\n\nconst columnConfig: ColumnConfig[] = [\n {\n key: 'name',\n allowsSorting: true,\n visibleInMode: 'all',\n header: () => ,\n body: product => {\n const price = product.prices[0];\n return (\n \n ) : (\n \n )\n }\n />\n );\n },\n },\n {\n key: 'created_at',\n allowsSorting: true,\n maxWidth: 'max-w-100',\n header: () => ,\n body: product => ,\n },\n {\n key: 'updated_at',\n allowsSorting: true,\n maxWidth: 'max-w-100',\n header: () => ,\n body: product => ,\n },\n {\n key: 'actions',\n header: () => ,\n visibleInMode: 'all',\n hideHeader: true,\n align: 'end',\n maxWidth: 'max-w-84',\n body: product => {\n return (\n \n \n \n \n \n \n );\n },\n },\n];\n\nexport function PlansIndexPage() {\n const navigate = useNavigate();\n return (\n }\n columns={columnConfig}\n actions={}\n enableSelection={false}\n filters={PlansIndexPageFilters}\n onRowAction={item => {\n navigate(`/admin/plans/${item.id}/edit`);\n }}\n emptyStateMessage={\n }\n filteringTitle={}\n />\n }\n />\n );\n}\n\ninterface DeleteProductButtonProps {\n product: Product;\n}\nfunction DeleteProductButton({product}: DeleteProductButtonProps) {\n const deleteProduct = useDeleteProduct();\n return (\n {\n if (confirmed) {\n deleteProduct.mutate({productId: product.id});\n }\n }}\n >\n }>\n \n \n \n \n }\n body={}\n confirm={}\n />\n \n );\n}\n\nfunction Actions() {\n const syncPlans = useSyncProducts();\n return (\n \n }>\n {\n syncPlans.mutate();\n }}\n >\n \n \n \n \n \n \n \n );\n}\n","import {useQuery} from '@tanstack/react-query';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {apiClient} from '@common/http/query-client';\nimport {useParams} from 'react-router-dom';\nimport {Product} from '@common/billing/product';\n\nconst Endpoint = (id: number | string) => `billing/products/${id}`;\n\nexport interface FetchRoleResponse extends BackendResponse {\n product: Product;\n}\n\nexport function useProduct() {\n const {productId} = useParams();\n return useQuery({\n queryKey: [Endpoint(productId!)],\n queryFn: () => fetchProduct(productId!),\n });\n}\n\nfunction fetchProduct(productId: number | string): Promise {\n return apiClient.get(Endpoint(productId)).then(response => response.data);\n}\n","import {message} from '../../../i18n/message';\n\nexport const BillingPeriodPresets = [\n {\n key: 'day1',\n label: message('Daily'),\n interval: 'day',\n interval_count: 1,\n },\n {\n key: 'week1',\n label: message('Weekly'),\n interval: 'week',\n interval_count: 1,\n },\n {\n key: 'month1',\n label: message('Monthly'),\n interval: 'month',\n interval_count: 1,\n },\n {\n key: 'month3',\n label: message('Every 3 months'),\n interval: 'month',\n interval_count: 3,\n },\n {\n key: 'month6',\n label: message('Every 6 months'),\n interval: 'month',\n interval_count: 6,\n },\n {\n key: 'year1',\n label: message('Yearly'),\n interval: 'year',\n interval_count: 1,\n },\n {\n key: 'custom',\n label: message('Custom'),\n interval: null,\n interval_count: null,\n },\n];\n","import {useFormContext} from 'react-hook-form';\nimport {Product} from '@common/billing/product';\nimport React, {Fragment, useMemo, useState} from 'react';\nimport {useValueLists} from '@common/http/value-lists';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {Trans} from '@common/i18n/trans';\nimport {Item} from '@common/ui/forms/listbox/item';\nimport {FormSelect, Select} from '@common/ui/forms/select/select';\nimport {Price} from '@common/billing/price';\nimport {BillingPeriodPresets} from '@common/admin/plans/crupdate-plan-page/billing-period-presets';\nimport {Button} from '@common/ui/buttons/button';\nimport {message} from '@common/i18n/message';\nimport {useTrans} from '@common/i18n/use-trans';\n\ninterface PriceFormProps {\n index: number;\n onRemovePrice: () => void;\n}\nexport function PriceForm({index, onRemovePrice}: PriceFormProps) {\n const {trans} = useTrans();\n const query = useValueLists(['currencies']);\n const currencies = useMemo(() => {\n return query.data?.currencies ? Object.values(query.data.currencies) : [];\n }, [query.data]);\n const {watch, getValues} = useFormContext();\n const isNewProduct = !watch('id');\n const isNewPrice = watch(`prices.${index}.id`) == null;\n const subscriberCount = watch(`prices.${index}.subscriptions_count`) || 0;\n\n // select billing period preset based on price \"interval\" and \"interval_count\"\n const [billingPeriodPreset, setBillingPeriodPreset] = useState(() => {\n const interval = getValues(`prices.${index}.interval`);\n const intervalCount = getValues(`prices.${index}.interval_count`);\n const preset = BillingPeriodPresets.find(\n p => p.key === `${interval}${intervalCount}`\n );\n return preset ? preset.key : 'custom';\n });\n\n const allowPriceChanges = isNewProduct || isNewPrice || !subscriberCount;\n\n return (\n \n {!allowPriceChanges && (\n

\n \n

\n )}\n\n }\n type=\"number\"\n min={0.1}\n step={0.01}\n name={`prices.${index}.amount`}\n className=\"mb-20\"\n />\n }\n name={`prices.${index}.currency`}\n items={currencies}\n showSearchField\n searchPlaceholder={trans(message('Search currencies'))}\n selectionMode=\"single\"\n className=\"mb-20\"\n >\n {item => (\n {`${item.code}: ${item.name}`}\n )}\n \n \n {billingPeriodPreset === 'custom' && (\n \n )}\n
\n {\n onRemovePrice();\n }}\n >\n \n \n
\n
\n );\n}\n\ninterface BillingPeriodSelectProps {\n index: number;\n value: string;\n onValueChange: (value: string) => void;\n disabled: boolean;\n}\nfunction BillingPeriodSelect({\n index,\n value,\n onValueChange,\n disabled,\n}: BillingPeriodSelectProps) {\n const {setValue: setFormValue} = useFormContext();\n\n return (\n }\n disabled={disabled}\n className=\"mb-20\"\n selectionMode=\"single\"\n selectedValue={value}\n onSelectionChange={value => {\n onValueChange(value as string);\n if (value === 'custom') {\n } else {\n const preset = BillingPeriodPresets.find(p => p.key === value);\n if (preset) {\n setFormValue(\n `prices.${index}.interval`,\n preset.interval as Price['interval']\n );\n setFormValue(\n `prices.${index}.interval_count`,\n preset.interval_count as number\n );\n }\n }\n }}\n >\n {BillingPeriodPresets.map(preset => (\n \n \n \n ))}\n \n );\n}\n\ninterface CustomBillingPeriodFieldProps {\n index: number;\n disabled: boolean;\n}\nfunction CustomBillingPeriodField({\n index,\n disabled,\n}: CustomBillingPeriodFieldProps) {\n const {watch} = useFormContext();\n const interval = watch(`prices.${index}.interval`);\n let maxIntervalCount: number;\n\n if (interval === 'day') {\n maxIntervalCount = 365;\n } else if (interval === 'week') {\n maxIntervalCount = 52;\n } else {\n maxIntervalCount = 12;\n }\n\n return (\n
\n
\n \n
\n \n \n \n \n \n \n \n \n \n \n \n \n
\n );\n}\n","import {FormTextField} from '../../../ui/forms/input-field/text-field/text-field';\nimport {Trans} from '../../../i18n/trans';\nimport React, {Fragment, ReactNode} from 'react';\nimport {useFieldArray, useFormContext} from 'react-hook-form';\nimport {Accordion, AccordionItem} from '../../../ui/accordion/accordion';\nimport {FormattedPrice} from '../../../i18n/formatted-price';\nimport {FormPermissionSelector} from '../../../auth/ui/permission-selector';\nimport {PriceForm} from './price-form';\nimport {Button} from '../../../ui/buttons/button';\nimport {AddIcon} from '../../../icons/material/Add';\nimport {IconButton} from '../../../ui/buttons/icon-button';\nimport {CloseIcon} from '../../../icons/material/Close';\nimport {CreateProductPayload} from '../requests/use-create-product';\nimport {FormSwitch} from '../../../ui/forms/toggle/switch';\nimport {FormSelect} from '../../../ui/forms/select/select';\nimport {Item} from '../../../ui/forms/listbox/item';\nimport {FormFileSizeField} from '../../../ui/forms/input-field/file-size-field';\nimport {Link} from 'react-router-dom';\nimport {LinkStyle} from '../../../ui/buttons/external-link';\n\nexport function CrupdatePlanForm() {\n return (\n \n }\n className=\"mb-20\"\n required\n autoFocus\n />\n }\n className=\"mb-20\"\n inputElementType=\"textarea\"\n rows={4}\n />\n }\n className=\"mb-20\"\n >\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n }\n description={\n (\n \n {parts}\n \n ),\n }}\n message=\"Total storage space all user uploads are allowed to take up.\"\n />\n }\n />\n \n }\n >\n \n \n \n }\n >\n \n \n \n }\n >\n \n \n
\n \n
\n \n \n
\n \n
\n \n
\n );\n}\n\ninterface HeaderProps {\n children: ReactNode;\n}\nfunction Header({children}: HeaderProps) {\n return

{children}

;\n}\n\nfunction FeatureListForm() {\n const {fields, append, remove} = useFieldArray({\n name: 'feature_list',\n });\n return (\n
\n {fields.map((field, index) => {\n return (\n
\n \n {\n remove(index);\n }}\n >\n \n \n
\n );\n })}\n }\n size=\"xs\"\n onClick={() => {\n append({value: ''});\n }}\n >\n \n \n
\n );\n}\n\nfunction PricingListForm() {\n const {\n watch,\n formState: {errors},\n } = useFormContext();\n const {fields, append, remove} = useFieldArray<\n CreateProductPayload,\n 'prices',\n 'key'\n >({\n name: 'prices',\n keyName: 'key',\n });\n\n // if plan is marked as free, hide pricing form\n if (watch('free')) {\n return null;\n }\n\n return (\n \n
\n \n
\n {errors.prices?.message && (\n
{errors.prices.message}
\n )}\n \n {fields.map((field, index) => (\n }\n key={field.key}\n >\n {\n remove(index);\n }}\n />\n \n ))}\n \n }\n size=\"xs\"\n onClick={() => {\n append({\n currency: 'USD',\n amount: 1,\n interval_count: 1,\n interval: 'month',\n });\n }}\n >\n \n \n
\n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient, queryClient} from '../../../http/query-client';\nimport {BackendResponse} from '../../../http/backend-response/backend-response';\nimport {toast} from '../../../ui/toast/toast';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {message} from '../../../i18n/message';\nimport {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';\nimport {Product} from '../../../billing/product';\nimport {useNavigate} from '../../../utils/hooks/use-navigate';\nimport {CreateProductPayload} from './use-create-product';\nimport {UseFormReturn} from 'react-hook-form';\nimport {onFormQueryError} from '../../../errors/on-form-query-error';\n\ninterface Response extends BackendResponse {\n product: Product;\n}\n\nexport interface UpdateProductPayload extends CreateProductPayload {\n id: number;\n}\n\nconst Endpoint = (id: number) => `billing/products/${id}`;\n\nexport function useUpdateProduct(form: UseFormReturn) {\n const {trans} = useTrans();\n const navigate = useNavigate();\n return useMutation({\n mutationFn: (payload: UpdateProductPayload) => updateProduct(payload),\n onSuccess: response => {\n toast(trans(message('Plan updated')));\n queryClient.invalidateQueries({\n queryKey: [Endpoint(response.product.id)],\n });\n queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey('billing/products'),\n });\n navigate('/admin/plans');\n },\n onError: err => onFormQueryError(err, form),\n });\n}\n\nfunction updateProduct({\n id,\n ...payload\n}: UpdateProductPayload): Promise {\n const backendPayload = {\n ...payload,\n feature_list: payload.feature_list.map(feature => feature.value),\n };\n return apiClient.put(Endpoint(id), backendPayload).then(r => r.data);\n}\n","import {FullPageLoader} from '../../../ui/progress/full-page-loader';\nimport {Trans} from '../../../i18n/trans';\nimport {useForm} from 'react-hook-form';\nimport {CrupdateResourceLayout} from '../../crupdate-resource-layout';\nimport {useProduct} from '../requests/use-product';\nimport {Product} from '../../../billing/product';\nimport {CrupdatePlanForm} from './crupdate-plan-form';\nimport {\n UpdateProductPayload,\n useUpdateProduct,\n} from '../requests/use-update-product';\n\nexport function EditPlanPage() {\n const query = useProduct();\n\n if (query.status !== 'success') {\n return ;\n }\n\n return ;\n}\n\ninterface PageContentProps {\n product: Product;\n}\nfunction PageContent({product}: PageContentProps) {\n const form = useForm({\n defaultValues: {\n ...product,\n feature_list: product.feature_list.map(f => ({value: f})),\n },\n });\n const updateProduct = useUpdateProduct(form);\n\n return (\n {\n updateProduct.mutate(values);\n }}\n title={\n \n }\n isLoading={updateProduct.isPending}\n >\n \n \n );\n}\n","import {Product} from '../../../billing/product';\nimport {useTrans} from '../../../i18n/use-trans';\nimport {useNavigate} from '../../../utils/hooks/use-navigate';\nimport {useMutation} from '@tanstack/react-query';\nimport {toast} from '../../../ui/toast/toast';\nimport {message} from '../../../i18n/message';\nimport {apiClient, queryClient} from '../../../http/query-client';\nimport {DatatableDataQueryKey} from '../../../datatable/requests/paginated-resources';\nimport {Price} from '../../../billing/price';\nimport {onFormQueryError} from '../../../errors/on-form-query-error';\nimport {UseFormReturn} from 'react-hook-form';\n\nconst endpoint = 'billing/products';\n\nexport interface CreateProductPayload\n extends Omit, 'feature_list' | 'prices'> {\n feature_list: {value: string}[];\n prices: Omit[];\n}\n\nexport function useCreateProduct(form: UseFormReturn) {\n const {trans} = useTrans();\n const navigate = useNavigate();\n return useMutation({\n mutationFn: (payload: CreateProductPayload) => createProduct(payload),\n onSuccess: () => {\n toast(trans(message('Plan created')));\n queryClient.invalidateQueries({queryKey: [endpoint]});\n queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey('billing/products'),\n });\n navigate('/admin/plans');\n },\n onError: err => onFormQueryError(err, form),\n });\n}\n\nfunction createProduct(payload: CreateProductPayload): Promise {\n const backendPayload = {\n ...payload,\n feature_list: payload.feature_list.map(feature => feature.value),\n };\n return apiClient.post(endpoint, backendPayload).then(r => r.data);\n}\n","import {useForm} from 'react-hook-form';\nimport {CrupdateResourceLayout} from '../../crupdate-resource-layout';\nimport {Trans} from '../../../i18n/trans';\nimport {CrupdatePlanForm} from './crupdate-plan-form';\nimport {\n CreateProductPayload,\n useCreateProduct,\n} from '../requests/use-create-product';\n\nexport function CreatePlanPage() {\n const form = useForm({\n defaultValues: {\n free: false,\n recommended: false,\n },\n });\n const createProduct = useCreateProduct(form);\n\n return (\n {\n createProduct.mutate(values);\n }}\n title={}\n isLoading={createProduct.isPending}\n >\n \n \n );\n}\n","import {SettingsPanel} from '../settings-panel';\nimport {SettingsSeparator} from '../settings-separator';\nimport {Trans} from '../../../i18n/trans';\nimport {FormSwitch} from '../../../ui/forms/toggle/switch';\nimport {useFieldArray, useFormContext} from 'react-hook-form';\nimport {AdminSettings} from '../admin-settings';\nimport React, {Fragment} from 'react';\nimport {FormSelect} from '../../../ui/forms/select/select';\nimport {Item} from '../../../ui/forms/listbox/item';\nimport {MenuItemForm} from '../../menus/menu-item-form';\nimport {Button} from '../../../ui/buttons/button';\nimport {AddIcon} from '../../../icons/material/Add';\nimport {DialogTrigger} from '../../../ui/overlays/dialog/dialog-trigger';\nimport {AddMenuItemDialog} from '../../appearance/sections/menus/add-menu-item-dialog';\nimport {Accordion, AccordionItem} from '../../../ui/accordion/accordion';\nimport {IconButton} from '../../../ui/buttons/icon-button';\nimport {CloseIcon} from '../../../icons/material/Close';\n\nexport function GdprSettings() {\n return (\n }\n description={\n \n }\n >\n \n \n \n \n );\n}\n\nfunction CookieNoticeSection() {\n const {watch} = useFormContext();\n const noticeEnabled = watch('client.cookie_notice.enable');\n\n return (\n
\n \n }\n >\n \n \n {noticeEnabled && (\n \n
\n
\n \n
\n \n
\n }\n className=\"mb-20\"\n >\n \n \n \n \n \n \n \n
\n )}\n
\n );\n}\n\nfunction RegistrationPoliciesSection() {\n const {fields, append, remove} = useFieldArray<\n AdminSettings,\n 'client.registration.policies'\n >({\n name: 'client.registration.policies',\n });\n\n return (\n \n
\n \n
\n
\n \n
\n \n {fields.map((field, index) => (\n {\n remove(index);\n }}\n >\n \n \n }\n >\n \n \n ))}\n \n {\n if (value) {\n append(value);\n }\n }}\n >\n }\n size=\"xs\"\n >\n \n \n } />\n \n
\n );\n}\n","import {createSvgIcon} from '@common/icons/create-svg-icon';\n\nexport const InfoDialogTriggerIcon = createSvgIcon(\n ,\n 'InfoDialogTrigger'\n);\n","import {IconButton} from '@common/ui/buttons/icon-button';\nimport {InfoDialogTriggerIcon} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger-icon';\nimport {Dialog, DialogSize} from '@common/ui/overlays/dialog/dialog';\nimport {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';\nimport {DialogBody} from '@common/ui/overlays/dialog/dialog-body';\nimport {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';\nimport React, {ReactNode} from 'react';\nimport clsx from 'clsx';\n\ninterface Props {\n title?: ReactNode;\n body: ReactNode;\n dialogSize?: DialogSize;\n className?: string;\n}\nexport function InfoDialogTrigger({\n title,\n body,\n dialogSize = 'sm',\n className,\n}: Props) {\n return (\n \n \n \n \n \n {title && (\n \n {title}\n \n )}\n {body}\n \n \n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const HomeIcon = createSvgIcon(\n \n, 'HomeOutlined');\n","import {ColumnConfig} from '@common/datatable/column-config';\nimport {Trans} from '@common/i18n/trans';\nimport {FormattedDate} from '@common/i18n/formatted-date';\nimport {Link} from 'react-router-dom';\nimport {IconButton} from '@common/ui/buttons/icon-button';\nimport {EditIcon} from '@common/icons/material/Edit';\nimport React from 'react';\nimport {Channel} from '@common/channels/channel';\nimport {Chip} from '@common/ui/forms/input-field/chip-field/chip';\nimport {Tooltip} from '@common/ui/tooltip/tooltip';\nimport {useSettings} from '@common/core/settings/use-settings';\nimport {HomeIcon} from '@common/icons/material/Home';\n\nexport const ChannelsDatatableColumns: ColumnConfig[] = [\n {\n key: 'name',\n allowsSorting: true,\n width: 'flex-3',\n visibleInMode: 'all',\n header: () => ,\n body: channel => {\n return (\n
\n
\n \n
\n {channel.config.adminDescription && (\n

\n {channel.config.adminDescription}\n

\n )}\n
\n );\n },\n },\n {\n key: 'content',\n allowsSorting: false,\n header: () => ,\n body: channel => ,\n },\n {\n key: 'content_type',\n allowsSorting: false,\n header: () => ,\n body: channel => (\n \n {channel.config.contentModel ? (\n \n ) : undefined}\n \n ),\n },\n {\n key: 'internal',\n allowsSorting: true,\n maxWidth: 'max-w-100',\n hideHeader: true,\n header: () => ,\n body: channel => ,\n },\n {\n key: 'updated_at',\n allowsSorting: true,\n maxWidth: 'max-w-100',\n header: () => ,\n body: channel =>\n channel.updated_at ? : '',\n },\n {\n key: 'actions',\n header: () => ,\n hideHeader: true,\n visibleInMode: 'all',\n align: 'end',\n width: 'w-42 flex-shrink-0',\n body: channel => (\n \n \n \n \n \n ),\n },\n];\n\ninterface ContentTypeProps {\n channel: Channel;\n}\nfunction ContentType({channel}: ContentTypeProps) {\n switch (channel.config.contentType) {\n case 'listAll':\n return ;\n case 'manual':\n return ;\n case 'autoUpdate':\n return ;\n }\n}\n\ninterface ChannelNameProps {\n channel: Channel;\n}\nfunction ChannelName({channel}: ChannelNameProps) {\n // link will not work without specific genre name in channel url\n if (\n channel.config.restriction &&\n channel.config.restrictionModelId === 'urlParam'\n ) {\n return channel.name;\n }\n return (\n \n {channel.name}\n \n );\n}\n\nfunction InternalColumn({channel}: ChannelNameProps) {\n const {homepage} = useSettings();\n const internalLabel = channel.internal ? (\n \n }\n >\n
\n \n \n \n
\n \n ) : (\n ''\n );\n\n const isHomepage =\n homepage?.type === 'channels' && `${homepage.value}` === `${channel.id}`;\n\n return (\n
\n {internalLabel}\n {isHomepage ? : null}\n
\n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {toast} from '@common/ui/toast/toast';\nimport {message} from '@common/i18n/message';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {}\n\ninterface Payload {\n preset: string;\n}\n\nexport function useApplyChannelPreset() {\n const {trans} = useTrans();\n return useMutation({\n mutationFn: (payload: Payload) => resetChannels(payload),\n onSuccess: async () => {\n await queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey('channel'),\n });\n toast(trans(message('Channel preset applied')));\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction resetChannels(payload: Payload) {\n return apiClient\n .post('channel/apply-preset', payload)\n .then(r => r.data);\n}\n","import {LearnMoreLink} from '@common/admin/settings/learn-more-link';\nimport {useContext} from 'react';\nimport {SiteConfigContext} from '@common/core/settings/site-config-context';\n\ninterface Props {\n className?: string;\n hash?: string;\n}\nexport function ChannelsDocsLink({className, hash}: Props) {\n const {admin} = useContext(SiteConfigContext);\n if (!admin?.channelsDocsLink) return null;\n const link = hash\n ? `${admin.channelsDocsLink}#${hash}`\n : admin.channelsDocsLink;\n return ;\n}\n","import React, {Fragment} from 'react';\nimport {Trans} from '@common/i18n/trans';\nimport {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';\nimport playlist from './playlist.svg';\nimport {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';\nimport {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';\nimport {Link} from 'react-router-dom';\nimport {ChannelsDatatableColumns} from '@common/admin/channels/channels-datatable-columns';\nimport {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';\nimport {useApplyChannelPreset} from '@common/admin/channels/requests/use-apply-channel-preset';\nimport {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';\nimport {DataTablePage} from '@common/datatable/page/data-table-page';\nimport {DeleteSelectedItemsAction} from '@common/datatable/page/delete-selected-items-action';\nimport {useDataTable} from '@common/datatable/page/data-table-context';\nimport {Channel} from '@common/channels/channel';\nimport {Menu, MenuTrigger} from '@common/ui/navigation/menu/menu-trigger';\nimport {Button} from '@common/ui/buttons/button';\nimport {Item} from '@common/ui/forms/listbox/item';\nimport {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';\nimport {openDialog} from '@common/ui/overlays/store/dialog-store';\nimport {ChannelsDocsLink} from '@common/admin/channels/channels-docs-link';\n\ninterface ChannelPresetConfig {\n preset: string;\n name: string;\n description: string;\n}\n\nexport function ChannelsDatatablePage() {\n return (\n }\n headerContent={}\n headerItemsAlign=\"items-center\"\n queryParams={{type: 'channel'}}\n columns={ChannelsDatatableColumns}\n actions={}\n selectedActions={}\n cellHeight=\"h-52\"\n emptyStateMessage={\n }\n filteringTitle={}\n />\n }\n />\n );\n}\n\nfunction InfoTrigger() {\n return (\n \n \n \n \n }\n />\n );\n}\n\nfunction Actions() {\n const {query} = useDataTable();\n return (\n \n openDialog(ApplyPresetDialog, {preset})}\n >\n }\n disabled={!query.data?.presets.length}\n >\n \n \n \n {query.data?.presets.map(preset => (\n }\n >\n \n \n ))}\n \n \n \n \n \n \n );\n}\n\ninterface ApplyPresetDialogProps {\n preset: string;\n}\nfunction ApplyPresetDialog({preset}: ApplyPresetDialogProps) {\n const {close} = useDialogContext();\n const resetChannels = useApplyChannelPreset();\n return (\n {\n resetChannels.mutate({preset}, {onSuccess: () => close()});\n }}\n isDanger\n title={}\n body={\n \n }\n confirm={}\n />\n );\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {useNavigate} from '@common/utils/hooks/use-navigate';\nimport {toast} from '@common/ui/toast/toast';\nimport {message} from '@common/i18n/message';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';\nimport {onFormQueryError} from '@common/errors/on-form-query-error';\nimport {UseFormReturn} from 'react-hook-form';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {Channel} from '@common/channels/channel';\nimport {CreateChannelPayload} from '@common/admin/channels/requests/use-create-channel';\n\ninterface Response extends BackendResponse {\n channel: Channel;\n}\n\nexport interface UpdateChannelPayload extends CreateChannelPayload {\n id: number;\n}\n\nconst Endpoint = (id: number) => `channel/${id}`;\n\nexport function useUpdateChannel(form: UseFormReturn) {\n const {trans} = useTrans();\n const navigate = useNavigate();\n return useMutation({\n mutationFn: (payload: UpdateChannelPayload) => updateChannel(payload),\n onSuccess: async () => {\n await queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey('channel'),\n });\n toast(trans(message('Channel updated')));\n navigate('/admin/channels');\n },\n onError: err => onFormQueryError(err, form),\n });\n}\n\nfunction updateChannel({\n id,\n ...payload\n}: UpdateChannelPayload): Promise {\n return apiClient.put(Endpoint(id), payload).then(r => r.data);\n}\n","import {useForm} from 'react-hook-form';\nimport React, {ReactNode} from 'react';\nimport {CrupdateResourceLayout} from '@common/admin/crupdate-resource-layout';\nimport {Trans} from '@common/i18n/trans';\nimport {PageStatus} from '@common/http/page-status';\nimport {useChannel} from '@common/channels/requests/use-channel';\nimport {Channel} from '@common/channels/channel';\nimport {\n UpdateChannelPayload,\n useUpdateChannel,\n} from '@common/admin/channels/requests/use-update-channel';\n\ninterface Props {\n children: ReactNode;\n}\nexport function EditChannelPageLayout({children}: Props) {\n const query = useChannel(undefined, 'editChannelPage');\n if (query.data) {\n return {children};\n }\n return ;\n}\n\ninterface PageContentProps {\n channel: Channel;\n children: ReactNode;\n}\nfunction PageContent({channel, children}: PageContentProps) {\n const form = useForm({\n // @ts-ignore\n defaultValues: {\n ...channel,\n },\n });\n const updateChannel = useUpdateChannel(form);\n\n return (\n {\n updateChannel.mutate(values);\n }}\n title={\n \n }\n isLoading={updateChannel.isPending}\n >\n {children}\n \n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const DescriptionIcon = createSvgIcon(\n \n, 'DescriptionOutlined');\n","import React, {Fragment, useEffect, useState} from 'react';\nimport clsx from 'clsx';\nimport {RefCallBack} from 'react-hook-form';\nimport {Button} from './buttons/button';\nimport {LinkIcon} from '../icons/material/Link';\nimport {TextField} from './forms/input-field/text-field/text-field';\nimport {Trans} from '../i18n/trans';\nimport {useSettings} from '../core/settings/use-settings';\nimport {slugifyString} from '@common/utils/string/slugify-string';\n\nexport interface SlugEditorProps {\n prefix?: string;\n suffix?: string;\n host?: string;\n value?: string | null;\n placeholder?: string;\n onChange?: (value: string) => void;\n className?: string;\n inputRef?: RefCallBack;\n onInputBlur?: () => void;\n showLinkIcon?: boolean;\n pattern?: string;\n minLength?: number;\n maxLength?: number;\n hideButton?: boolean;\n}\nexport function SlugEditor({\n host,\n value: initialValue = '',\n placeholder,\n onChange,\n className,\n inputRef,\n onInputBlur,\n showLinkIcon = true,\n pattern,\n minLength,\n maxLength,\n hideButton,\n ...props\n}: SlugEditorProps) {\n const {base_url} = useSettings();\n const prefix = props.prefix ? `/${props.prefix}` : '';\n const suffix = props.suffix ? `/${props.suffix}` : '';\n const [isEditing, setIsEditing] = useState(false);\n const [value, setValue] = useState(initialValue);\n host = host || base_url;\n\n useEffect(() => {\n setValue(initialValue);\n }, [initialValue]);\n\n const handleSubmit = () => {\n if (!isEditing) {\n setIsEditing(true);\n } else {\n setIsEditing(false);\n if (value) {\n onChange?.(value);\n }\n }\n };\n\n let preview: string = '';\n if (value) {\n preview = value;\n } else if (placeholder) {\n preview = slugifyString(placeholder);\n }\n\n return (\n // can't use
here as component might be used inside another form\n
\n {showLinkIcon && }\n
\n {host}\n {prefix}\n {!isEditing && preview && (\n \n /\n {preview}\n \n )}\n {!isEditing ? suffix : null}\n
\n {isEditing && (\n {\n if (e.key === 'Enter') {\n handleSubmit();\n }\n }}\n ref={inputRef}\n aria-label=\"slug\"\n autoFocus\n className=\"mr-14\"\n size=\"2xs\"\n value={value as string}\n onBlur={onInputBlur}\n onChange={e => {\n setValue(e.target.value);\n }}\n />\n )}\n {!hideButton && (\n {\n handleSubmit();\n }}\n >\n {isEditing ? : }\n \n )}\n
\n );\n}\n","import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {Trans} from '@common/i18n/trans';\nimport React, {Fragment} from 'react';\nimport {useFormContext} from 'react-hook-form';\nimport {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';\nimport {SlugEditor} from '@common/ui/slug-editor';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {message} from '@common/i18n/message';\nimport clsx from 'clsx';\n\ninterface Props {\n className?: string;\n autoFocus?: boolean;\n}\nexport function ChannelNameField({className, autoFocus}: Props) {\n return (\n \n }\n required\n autoFocus={autoFocus}\n className={clsx('mb-10', className)}\n />\n \n \n );\n}\n\nfunction FormSlugField() {\n const {watch, setValue} = useFormContext();\n const value = watch('slug');\n const name = watch('name');\n const disableSlugEditing = watch('config.lockSlug');\n const restriction = watch('config.restriction');\n const restrictionId = watch('config.restrictionModelId');\n const {trans} = useTrans();\n return (\n {\n setValue('slug', newSlug);\n }}\n />\n );\n}\n","import {useFormContext} from 'react-hook-form';\nimport {FormSelect, Option} from '@common/ui/forms/select/select';\nimport {Trans} from '@common/i18n/trans';\nimport {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';\nimport {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';\n\ninterface Props {\n config: ChannelContentConfig;\n className?: string;\n}\nexport function ContentTypeField({config, className}: Props) {\n const {setValue} = useFormContext();\n return (\n }\n onSelectionChange={newValue => {\n // if content type is \"auto update\" select first model that\n // can be auto updated, otherwise select first available model\n let model = Object.entries(config.models)[0];\n if (newValue === 'autoUpdate') {\n const newModel = Object.entries(config.models).find(\n ([, modelConfig]) => modelConfig.autoUpdateMethods?.length,\n );\n if (newModel) {\n model = newModel;\n }\n }\n const [modelName, modelConfig] = model;\n\n setValue('config.contentModel', modelName);\n setValue('config.restrictionModelId', undefined);\n setValue(\n 'config.autoUpdateMethod',\n newValue === 'autoUpdate' ? modelConfig.autoUpdateMethods?.[0] : '',\n );\n setValue('config.contentOrder', modelConfig.sortMethods[0]);\n (setValue as any)('config.restriction', null);\n }}\n >\n \n \n \n \n );\n}\n","import {useFormContext} from 'react-hook-form';\nimport {FormSelect, Option} from '@common/ui/forms/select/select';\nimport {Trans} from '@common/i18n/trans';\nimport {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';\nimport {Fragment, ReactNode} from 'react';\nimport {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';\nimport {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport clsx from 'clsx';\nimport {ChannelsDocsLink} from '@common/admin/channels/channels-docs-link';\n\ninterface Props {\n children?: ReactNode;\n config: ChannelContentConfig;\n className?: string;\n}\nexport function ContentAutoUpdateField({children, config, className}: Props) {\n const {watch, setValue} = useFormContext();\n const modelConfig = config.models[watch('config.contentModel')];\n const selectedMethodConfig =\n config.autoUpdateMethods[watch('config.autoUpdateMethod')!];\n\n if (\n watch('config.contentType') !== 'autoUpdate' ||\n !modelConfig.autoUpdateMethods?.length\n ) {\n return null;\n }\n\n return (\n
\n {\n if (config.autoUpdateMethods[value].provider) {\n setValue(\n 'config.autoUpdateProvider',\n config.autoUpdateMethods[value].provider,\n );\n }\n }}\n label={\n \n \n \n
\n \n
\n \n
\n }\n />\n \n }\n >\n {modelConfig.autoUpdateMethods.map(method => (\n \n ))}\n \n {selectedMethodConfig?.value ? (\n }\n type={selectedMethodConfig?.value.inputType}\n />\n ) : null}\n {children}\n
\n );\n}\n","import {useSettings} from '@common/core/settings/use-settings';\nimport {useFormContext} from 'react-hook-form';\nimport {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';\nimport {channelContentConfig} from '@app/admin/channels/channel-content-config';\nimport {ContentAutoUpdateField} from '@common/admin/channels/channel-editor/controls/content-auto-update-field';\nimport {FormSelect, Option} from '@common/ui/forms/select/select';\nimport {Trans} from '@common/i18n/trans';\nimport React from 'react';\n\ninterface Props {\n className?: string;\n}\nexport function ChannelAutoUpdateField({className}: Props) {\n const {tmdb_is_setup} = useSettings();\n const {watch} = useFormContext();\n const methodConfig =\n channelContentConfig.autoUpdateMethods[watch('config.autoUpdateMethod')!];\n return (\n \n {!methodConfig?.provider && tmdb_is_setup && (\n }\n required\n >\n \n \n \n )}\n \n );\n}\n","export const KEYWORD_MODEL = 'keyword';\n\nexport interface Keyword {\n id: number;\n name: string;\n display_name: string;\n updated_at: string;\n created_at: string;\n model_type: typeof KEYWORD_MODEL;\n}\n","import {Trans} from '@common/i18n/trans';\nimport {Item} from '@common/ui/forms/listbox/item';\nimport {FormSelect} from '@common/ui/forms/select/select';\nimport React, {Fragment, useState} from 'react';\nimport {GENRE_MODEL} from '@app/titles/models/genre';\nimport {KEYWORD_MODEL} from '@app/titles/models/keyword';\nimport {PRODUCTION_COUNTRY_MODEL} from '@app/titles/models/production-country';\nimport {useValueLists} from '@common/http/value-lists';\nimport {message} from '@common/i18n/message';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {useFormContext} from 'react-hook-form';\nimport {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';\nimport {MOVIE_MODEL, SERIES_MODEL, TITLE_MODEL} from '@app/titles/models/title';\nimport {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';\nimport clsx from 'clsx';\nimport {ChannelsDocsLink} from '@common/admin/channels/channels-docs-link';\n\nconst supportedModels = [TITLE_MODEL, MOVIE_MODEL, SERIES_MODEL];\n\nconst restrictions = {\n [GENRE_MODEL]: message('Genre'),\n [KEYWORD_MODEL]: message('Keyword'),\n [PRODUCTION_COUNTRY_MODEL]: message('Production country'),\n};\n\ninterface Props {\n className?: string;\n}\nexport function ChannelRestrictionField({className}: Props) {\n const {setValue} = useFormContext();\n const {watch} = useFormContext();\n\n if (!supportedModels.includes(watch('config.contentModel'))) {\n return null;\n }\n\n return (\n
\n \n \n \n \n }\n onSelectionChange={() => {\n setValue('config.restrictionModelId', 'urlParam');\n }}\n >\n \n \n \n {Object.entries(restrictions).map(([value, label]) => (\n \n \n \n ))}\n \n \n
\n );\n}\n\nfunction RestrictionModelField() {\n const {trans} = useTrans();\n const [searchValue, setSearchValue] = useState('');\n const {watch} = useFormContext();\n const {data} = useValueLists(['genres', 'productionCountries'], {\n type: watch('config.autoUpdateProvider'),\n });\n\n const selectedRestriction = watch(\n 'config.restriction',\n ) as keyof typeof restrictions;\n const selectedKeywordId = watch('config.restrictionModelId');\n const keywordQuery = useValueLists(['keywords'], {\n searchQuery: searchValue,\n selectedValue: selectedKeywordId,\n type: watch('config.autoUpdateProvider'),\n });\n\n if (!selectedRestriction) return null;\n\n const options = {\n [GENRE_MODEL]: data?.genres,\n [KEYWORD_MODEL]: keywordQuery.data?.keywords,\n [PRODUCTION_COUNTRY_MODEL]: data?.productionCountries,\n };\n const restrictionLabel = restrictions[selectedRestriction];\n\n // allow setting keyword to custom value, because there are too many keywords\n // to put into autocomplete, ideally it would use async search from backend though\n\n return (\n \n }\n >\n \n \n \n {options[selectedRestriction]?.map(option => (\n \n \n \n ))}\n \n );\n}\n\nfunction InfoTrigger() {\n return (\n \n \n \n \n }\n />\n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const DashboardIcon = createSvgIcon(\n \n, 'DashboardOutlined');\n","import {useFormContext} from 'react-hook-form';\nimport {FormSelect, Option} from '@common/ui/forms/select/select';\nimport {Trans} from '@common/i18n/trans';\nimport {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';\nimport {ReactNode} from 'react';\nimport {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';\nimport clsx from 'clsx';\n\ninterface Props {\n config: ChannelContentConfig;\n className?: string;\n}\nexport function ContentLayoutFields({config, className}: Props) {\n return (\n
\n }\n />\n }\n />\n
\n );\n}\n\ninterface LayoutFieldProps extends Props {\n name: string;\n label: ReactNode;\n}\nfunction LayoutField({config, name, label}: LayoutFieldProps) {\n const {watch} = useFormContext();\n const contentModel = watch('config.contentModel');\n const modelConfig = config.models[contentModel];\n\n if (!modelConfig.layoutMethods?.length) {\n return null;\n }\n\n return (\n \n {modelConfig.layoutMethods.map(method => {\n const label = config.layoutMethods[method].label;\n return (\n \n );\n })}\n \n );\n}\n","import {FormSelect, Option} from '@common/ui/forms/select/select';\nimport {Trans} from '@common/i18n/trans';\nimport {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';\n\ninterface Props {\n config: ChannelContentConfig;\n className?: string;\n}\nexport function ChannelPaginationTypeField({className}: Props) {\n return (\n }\n >\n \n \n \n \n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const PublicIcon = createSvgIcon(\n \n, 'PublicOutlined');\n","import React, {Fragment} from 'react';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {Trans} from '@common/i18n/trans';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {message} from '@common/i18n/message';\n\nexport function ChannelSeoFields() {\n const {trans} = useTrans();\n return (\n \n }\n className=\"mb-24\"\n placeholder={trans(message('Optional'))}\n />\n }\n inputElementType=\"textarea\"\n rows={6}\n placeholder={trans(message('Optional'))}\n />\n \n );\n}\n","import {EditChannelPageLayout} from '@common/admin/channels/channel-editor/edit-channel-page-layout';\nimport React, {Fragment} from 'react';\nimport {Accordion, AccordionItem} from '@common/ui/accordion/accordion';\nimport {Trans} from '@common/i18n/trans';\nimport {DescriptionIcon} from '@common/icons/material/Description';\nimport {ChannelNameField} from '@common/admin/channels/channel-editor/controls/channel-name-field';\nimport {FormSwitch} from '@common/ui/forms/toggle/switch';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';\nimport {SettingsIcon} from '@common/icons/material/Settings';\nimport {ContentTypeField} from '@common/admin/channels/channel-editor/controls/content-type-field';\nimport {channelContentConfig} from '@app/admin/channels/channel-content-config';\nimport {ChannelAutoUpdateField} from '@app/admin/channels/channel-auto-update-field';\nimport {ContentModelField} from '@common/admin/channels/channel-editor/controls/content-model-field';\nimport {ChannelRestrictionField} from '@app/admin/channels/channel-restriction-field';\nimport {ContentOrderField} from '@common/admin/channels/channel-editor/controls/content-order-field';\nimport {DashboardIcon} from '@common/icons/material/Dashboard';\nimport {ContentLayoutFields} from '@common/admin/channels/channel-editor/controls/content-layout-fields';\nimport {ChannelPaginationTypeField} from '@common/admin/channels/channel-editor/controls/channel-pagination-type-field';\nimport {PublicIcon} from '@common/icons/material/Public';\nimport {ChannelSeoFields} from '@app/admin/channels/channel-seo-fields';\nimport {ChannelContentEditor} from '@common/admin/channels/channel-editor/channel-content-editor';\nimport {\n ChannelContentSearchField,\n ChannelContentSearchFieldProps,\n} from '@common/admin/channels/channel-editor/channel-content-search-field';\nimport {ChannelContentItemImage} from '@app/admin/channels/channel-content-item-image';\n\nexport function EditChannelPage() {\n return (\n \n \n \n }\n startIcon={}\n >\n \n \n }\n >\n \n \n }\n inputElementType=\"textarea\"\n rows={1}\n className=\"mt-24\"\n />\n \n \n \n }\n />\n \n }\n inputElementType=\"textarea\"\n rows={1}\n className=\"mt-24\"\n />\n \n }\n startIcon={}\n >\n \n \n \n \n \n \n }\n startIcon={}\n >\n \n \n \n }\n startIcon={}\n >\n \n \n \n } />\n \n \n );\n}\n\nfunction SearchField(props: ChannelContentSearchFieldProps) {\n return (\n }\n />\n );\n}\n","import {useMutation, useQueryClient} from '@tanstack/react-query';\nimport {UseFormReturn} from 'react-hook-form';\nimport {apiClient} from '@common/http/query-client';\nimport {toast} from '@common/ui/toast/toast';\nimport {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';\nimport {useTrans} from '@common/i18n/use-trans';\nimport {onFormQueryError} from '@common/errors/on-form-query-error';\nimport {message} from '@common/i18n/message';\nimport {useNavigate} from '@common/utils/hooks/use-navigate';\nimport {PaginationResponse} from '@common/http/backend-response/pagination-response';\nimport {NormalizedModel} from '@common/datatable/filters/normalized-model';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {Channel} from '@common/channels/channel';\n\nconst endpoint = 'channel';\n\ninterface Response extends BackendResponse {\n channel: Channel;\n}\n\nexport interface CreateChannelPayload\n extends Omit {\n content: PaginationResponse;\n}\n\nexport function useCreateChannel(form: UseFormReturn) {\n const {trans} = useTrans();\n const navigate = useNavigate();\n const queryClient = useQueryClient();\n return useMutation({\n mutationFn: (payload: CreateChannelPayload) => createChannel(payload),\n onSuccess: async response => {\n await queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey(endpoint),\n });\n toast(trans(message('Channel created')));\n navigate(`/admin/channels/${response.channel.id}/edit`, {\n replace: true,\n });\n },\n onError: err => onFormQueryError(err, form),\n });\n}\n\nfunction createChannel(payload: CreateChannelPayload) {\n return apiClient.post(endpoint, payload).then(r => r.data);\n}\n","import {useForm} from 'react-hook-form';\nimport React, {ReactNode} from 'react';\nimport {CrupdateResourceLayout} from '@common/admin/crupdate-resource-layout';\nimport {Trans} from '@common/i18n/trans';\nimport {EMPTY_PAGINATION_RESPONSE} from '@common/http/backend-response/pagination-response';\nimport {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';\nimport {useCreateChannel} from '@common/admin/channels/requests/use-create-channel';\n\ninterface Props {\n defaultValues?: Partial;\n children: ReactNode;\n}\nexport function CreateChannelPageLayout({defaultValues, children}: Props) {\n const form = useForm({\n defaultValues: {\n content: EMPTY_PAGINATION_RESPONSE.pagination,\n config: {\n contentType: 'listAll',\n contentOrder: 'created_at:desc',\n nestedLayout: 'carousel',\n ...defaultValues,\n },\n },\n });\n const createChannel = useCreateChannel(form);\n\n return (\n {\n createChannel.mutate(values);\n }}\n title={}\n isLoading={createChannel.isPending}\n >\n {children}\n \n );\n}\n","import React, {ReactElement} from 'react';\nimport {CreateChannelPageLayout} from '@common/admin/channels/channel-editor/create-channel-page-layout';\nimport {MOVIE_MODEL} from '@app/titles/models/title';\nimport {Trans} from '@common/i18n/trans';\nimport {ChannelNameField} from '@common/admin/channels/channel-editor/controls/channel-name-field';\nimport {FormSwitch} from '@common/ui/forms/toggle/switch';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {ContentTypeField} from '@common/admin/channels/channel-editor/controls/content-type-field';\nimport {channelContentConfig} from '@app/admin/channels/channel-content-config';\nimport {ContentModelField} from '@common/admin/channels/channel-editor/controls/content-model-field';\nimport {ChannelRestrictionField} from '@app/admin/channels/channel-restriction-field';\nimport {ContentOrderField} from '@common/admin/channels/channel-editor/controls/content-order-field';\nimport {ContentLayoutFields} from '@common/admin/channels/channel-editor/controls/content-layout-fields';\nimport {ChannelPaginationTypeField} from '@common/admin/channels/channel-editor/controls/channel-pagination-type-field';\nimport {ChannelAutoUpdateField} from '@app/admin/channels/channel-auto-update-field';\nimport {ChannelSeoFields} from '@app/admin/channels/channel-seo-fields';\nimport clsx from 'clsx';\nimport {Tabs} from '@common/ui/tabs/tabs';\nimport {TabList} from '@common/ui/tabs/tab-list';\nimport {Tab} from '@common/ui/tabs/tab';\nimport {TabPanel, TabPanels} from '@common/ui/tabs/tab-panels';\n\nexport function CreateChannelPage() {\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n }\n >\n \n \n }\n inputElementType=\"textarea\"\n rows={2}\n className=\"my-24\"\n />\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n );\n}\n\ninterface TitleProps {\n children: ReactElement;\n className?: string;\n}\nfunction Title({children, className}: TitleProps) {\n return (\n

\n {children}\n

\n );\n}\n","import {BackendFilter} from '@common/datatable/filters/backend-filter';\nimport {message} from '@common/i18n/message';\nimport {\n createdAtFilter,\n updatedAtFilter,\n} from '@common/datatable/filters/timestamp-filters';\n\nexport const NewsDatatableFilters: BackendFilter[] = [\n createdAtFilter({\n description: message('Date article was created'),\n }),\n updatedAtFilter({\n description: message('Date article was last updated'),\n }),\n];\n","export default \"__VITE_ASSET__421a551f__\"","import {useMutation} from '@tanstack/react-query';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';\nimport {toast} from '@common/ui/toast/toast';\nimport {message} from '@common/i18n/message';\nimport {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';\n\ninterface Response extends BackendResponse {}\n\ninterface Payload {\n articleId: number;\n}\n\nexport function useDeleteNewsArticle() {\n return useMutation({\n mutationFn: (payload: Payload) => deleteArticle(payload),\n onError: err => showHttpErrorToast(err),\n onSuccess: async () => {\n await queryClient.invalidateQueries({\n queryKey: DatatableDataQueryKey('news'),\n });\n toast(message('Article deleted'));\n },\n });\n}\n\nfunction deleteArticle(payload: Payload): Promise {\n return apiClient.delete(`news/${payload.articleId}`).then(r => r.data);\n}\n","import {ColumnConfig} from '@common/datatable/column-config';\nimport {NewsArticle} from '@app/titles/models/news-article';\nimport {Trans} from '@common/i18n/trans';\nimport {FormattedDate} from '@common/i18n/formatted-date';\nimport {Link} from 'react-router-dom';\nimport {Tooltip} from '@common/ui/tooltip/tooltip';\nimport {IconButton} from '@common/ui/buttons/icon-button';\nimport {EditIcon} from '@common/icons/material/Edit';\nimport {useContext} from 'react';\nimport {TableContext} from '@common/ui/tables/table-context';\nimport clsx from 'clsx';\nimport {useDeleteNewsArticle} from '@app/admin/news/requests/use-delete-news-article';\nimport {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';\nimport {DeleteIcon} from '@common/icons/material/Delete';\nimport {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';\nimport {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';\nimport {NewsArticleLink} from '@app/news/news-article-link';\nimport {NewsArticleImage} from '@app/news/news-article-image';\n\nexport const newsDatatableColumns: ColumnConfig[] = [\n {\n key: 'name',\n width: 'flex-3 min-w-200',\n visibleInMode: 'all',\n header: () => ,\n body: article => ,\n },\n {\n key: 'updatedAt',\n allowsSorting: true,\n width: 'w-96',\n header: () => ,\n body: article => (\n \n ),\n },\n {\n key: 'actions',\n header: () => ,\n width: 'w-84 flex-shrink-0',\n hideHeader: true,\n align: 'end',\n visibleInMode: 'all',\n body: article => (\n
\n \n }>\n \n \n \n \n \n \n }>\n \n \n \n \n \n \n
\n ),\n },\n];\n\ninterface ArticleColumnProps {\n article: NewsArticle;\n}\nfunction ArticleColumn({article}: ArticleColumnProps) {\n const {isCollapsedMode} = useContext(TableContext);\n return (\n
\n \n
\n \n \n
\n {!isCollapsedMode && (\n

\n {article.body}\n

\n )}\n
\n
\n );\n}\n\ninterface DeleteArticleDialogProps {\n article: NewsArticle;\n}\nexport function DeleteArticleDialog({article}: DeleteArticleDialogProps) {\n const deleteArticle = useDeleteNewsArticle();\n const {close} = useDialogContext();\n return (\n }\n body={}\n confirm={}\n onConfirm={() => {\n deleteArticle.mutate(\n {articleId: article.id},\n {onSuccess: () => close()},\n );\n }}\n />\n );\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const PublishIcon = createSvgIcon(\n \n, 'PublishOutlined');\n","import {useMutation} from '@tanstack/react-query';\nimport {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {toast} from '@common/ui/toast/toast';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {message} from '@common/i18n/message';\nimport {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {}\n\nexport function useImportNewsArticles() {\n return useMutation({\n mutationFn: () => importArticles(),\n onSuccess: async () => {\n await queryClient.invalidateQueries({queryKey: ['news']});\n toast(message('Imported news articles'));\n },\n onError: r => showHttpErrorToast(r),\n });\n}\n\nfunction importArticles(): Promise {\n return apiClient.post(`news/import-from-remote-provider`).then(r => r.data);\n}\n","import {Fragment} from 'react';\nimport {Trans} from '@common/i18n/trans';\nimport {Link} from 'react-router-dom';\nimport {DataTablePage} from '@common/datatable/page/data-table-page';\nimport {NewsDatatableFilters} from '@app/admin/news/news-datatable-filters';\nimport {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';\nimport {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';\nimport {DeleteSelectedItemsAction} from '@common/datatable/page/delete-selected-items-action';\nimport onlineArticlesImg from '@app/admin/news/online-articles.svg';\nimport {newsDatatableColumns} from '@app/admin/news/news-datatable-columns';\nimport {IconButton} from '@common/ui/buttons/icon-button';\nimport {PublishIcon} from '@common/icons/material/Publish';\nimport {useImportNewsArticles} from '@app/admin/news/requests/use-import-news-articles';\nimport {Tooltip} from '@common/ui/tooltip/tooltip';\n\nexport function NewsDatatablePage() {\n return (\n }\n filters={NewsDatatableFilters}\n columns={newsDatatableColumns}\n queryParams={{\n stripHtml: 'true',\n truncateBody: 200,\n }}\n actions={}\n selectedActions={}\n enableSelection={false}\n cellHeight=\"h-80\"\n emptyStateMessage={\n }\n filteringTitle={}\n />\n }\n />\n );\n}\n\nfunction Actions() {\n const importArticles = useImportNewsArticles();\n return (\n \n }>\n importArticles.mutate()}\n disabled={importArticles.isPending}\n >\n \n \n \n \n \n \n \n );\n}\n","import {useDeleteComments} from '@common/comments/requests/use-delete-comments';\nimport {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';\nimport {queryClient} from '@common/http/query-client';\nimport {Button} from '@common/ui/buttons/button';\nimport {Trans} from '@common/i18n/trans';\nimport {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';\nimport React from 'react';\nimport {ButtonVariant} from '@common/ui/buttons/get-shared-button-style';\nimport {ButtonSize} from '@common/ui/buttons/button-size';\n\ninterface DeleteCommentsButtonProps {\n commentIds: number[];\n variant?: ButtonVariant;\n size?: ButtonSize;\n}\nexport function DeleteCommentsButton({\n commentIds,\n variant = 'outline',\n size = 'xs',\n}: DeleteCommentsButtonProps) {\n const deleteComments = useDeleteComments();\n return (\n {\n if (isConfirmed) {\n deleteComments.mutate(\n {commentIds},\n {\n onSuccess: () => {\n queryClient.invalidateQueries({queryKey: ['comment']});\n },\n },\n );\n }\n }}\n >\n \n \n \n \n }\n body={\n commentIds.length > 1 ? (\n \n ) : (\n \n )\n }\n confirm={}\n />\n \n );\n}\n","import {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {useMutation} from '@tanstack/react-query';\nimport {toast} from '@common/ui/toast/toast';\nimport {message} from '@common/i18n/message';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {\n //\n}\n\ninterface Payload {\n commentId: number;\n content: string;\n}\n\nexport function useUpdateComment() {\n return useMutation({\n mutationFn: (props: Payload) => updateComment(props),\n onSuccess: () => {\n toast(message('Comment updated'));\n queryClient.invalidateQueries({queryKey: ['comment']});\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction updateComment({commentId, content}: Payload): Promise {\n return apiClient.put(`comment/${commentId}`, {content}).then(r => r.data);\n}\n","import {useMutation} from '@tanstack/react-query';\nimport {apiClient} from '../../http/query-client';\nimport {BackendResponse} from '../../http/backend-response/backend-response';\nimport {toast} from '../../ui/toast/toast';\nimport {message} from '../../i18n/message';\nimport {showHttpErrorToast} from '../../utils/http/show-http-error-toast';\n\ninterface Response extends BackendResponse {\n //\n}\n\ninterface Payload {\n commentIds: number[];\n}\n\nexport function useRestoreComments() {\n return useMutation({\n mutationFn: (payload: Payload) => restoreComment(payload),\n onSuccess: (response, payload) => {\n toast(\n message('Restored [one 1 comment|other :count comments]', {\n values: {count: payload.commentIds.length},\n }),\n );\n },\n onError: err => showHttpErrorToast(err),\n });\n}\n\nfunction restoreComment({commentIds}: Payload): Promise {\n return apiClient.post('comment/restore', {commentIds}).then(r => r.data);\n}\n","import {queryClient} from '@common/http/query-client';\nimport {Button} from '@common/ui/buttons/button';\nimport {Trans} from '@common/i18n/trans';\nimport React from 'react';\nimport {ButtonVariant} from '@common/ui/buttons/get-shared-button-style';\nimport {ButtonSize} from '@common/ui/buttons/button-size';\nimport {useRestoreComments} from '@common/comments/requests/use-restore-comments';\n\ninterface Props {\n commentIds: number[];\n variant?: ButtonVariant;\n size?: ButtonSize;\n}\nexport function RestoreCommentsButton({\n commentIds,\n variant = 'outline',\n size = 'xs',\n}: Props) {\n const restoreComments = useRestoreComments();\n return (\n {\n restoreComments.mutate(\n {commentIds},\n {\n onSuccess: () => {\n queryClient.invalidateQueries({queryKey: ['comment']});\n },\n },\n );\n }}\n >\n \n \n );\n}\n","import {User} from '@common/auth/user';\nimport {Comment} from '@common/comments/comment';\nimport React, {Fragment, useContext, useState} from 'react';\nimport {Checkbox} from '@common/ui/forms/toggle/checkbox';\nimport {UserAvatar} from '@common/ui/images/user-avatar';\nimport {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';\nimport {queryClient} from '@common/http/query-client';\nimport {DeleteCommentsButton} from '@common/comments/comments-datatable-page/delete-comments-button';\nimport {Button} from '@common/ui/buttons/button';\nimport {Trans} from '@common/i18n/trans';\nimport {useUpdateComment} from '@common/comments/requests/use-update-comment';\nimport {TextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {SiteConfigContext} from '@common/core/settings/site-config-context';\nimport {Link} from 'react-router-dom';\nimport {LinkStyle} from '@common/ui/buttons/external-link';\nimport clsx from 'clsx';\nimport {RestoreCommentsButton} from '@common/comments/comments-datatable-page/restore-comments-button';\nimport {NormalizedModel} from '@common/datatable/filters/normalized-model';\n\ninterface Props {\n comment: Comment;\n isSelected: boolean;\n onToggle: () => void;\n}\nexport function CommentDatatableItem({comment, isSelected, onToggle}: Props) {\n const [isEditing, setIsEditing] = useState(false);\n return (\n
\n {comment.commentable && (\n \n )}\n
\n \n
\n \n {isEditing ? (\n {\n setIsEditing(false);\n if (isSaved) {\n queryClient.invalidateQueries({queryKey: ['comment']});\n }\n }}\n />\n ) : (\n \n
{comment.content}
\n
\n
\n {comment.deleted ? (\n \n ) : (\n \n )}\n {\n setIsEditing(true);\n }}\n >\n \n \n
\n
\n \n
\n
\n
\n )}\n
\n
\n
\n );\n}\n\ninterface CommentableHeaderProps {\n isSelected: boolean;\n onToggle: Props['onToggle'];\n commentable: NormalizedModel;\n}\nfunction CommentableHeader({\n isSelected,\n onToggle,\n commentable,\n}: CommentableHeaderProps) {\n return (\n
\n
\n onToggle()} />\n
\n {commentable.image && (\n \n )}\n
{commentable.name}
\n
({commentable.model_type})
\n
\n );\n}\n\ninterface CommentHeaderProps {\n comment: Comment;\n}\nfunction CommentHeader({comment}: CommentHeaderProps) {\n return (\n
\n
\n {comment.user && (\n \n )}\n
\n
\n \n {comment.user && (\n \n )}\n
\n );\n}\n\ninterface EditCommentFormProps {\n comment: Comment;\n onClose: (saved: boolean) => void;\n}\nfunction EditCommentForm({comment, onClose}: EditCommentFormProps) {\n const [content, setContent] = useState(comment.content);\n const updateComment = useUpdateComment();\n return (\n {\n e.preventDefault();\n updateComment.mutate(\n {commentId: comment.id, content},\n {onSuccess: () => onClose(true)},\n );\n }}\n >\n setContent(e.target.value)}\n />\n \n \n \n onClose(false)}\n disabled={updateComment.isPending}\n >\n \n \n \n );\n}\n\ninterface UserDisplayNameProps {\n user: User;\n show: 'display_name' | 'email';\n}\nfunction UserDisplayName({user, show}: UserDisplayNameProps) {\n const {auth} = useContext(SiteConfigContext);\n if (auth.getUserProfileLink) {\n return (\n \n {user[show]}\n \n );\n }\n return
{user[show]}
;\n}\n","export default \"__VITE_ASSET__a97a5552__\"","import {\n BackendFilter,\n FilterControlType,\n FilterOperator,\n} from '@common/datatable/filters/backend-filter';\nimport {message} from '@common/i18n/message';\nimport {USER_MODEL} from '@common/auth/user';\nimport {\n createdAtFilter,\n updatedAtFilter,\n} from '@common/datatable/filters/timestamp-filters';\n\nexport const CommentsDatatableFilters: BackendFilter[] = [\n {\n key: 'deleted',\n label: message('Status'),\n description: message('Whether comment is active or deleted'),\n defaultOperator: FilterOperator.eq,\n control: {\n type: FilterControlType.Select,\n defaultValue: '01',\n options: [\n {\n key: '01',\n label: message('Active'),\n value: false,\n },\n {\n key: '02',\n label: message('Deleted'),\n value: true,\n },\n ],\n },\n },\n {\n key: 'reports',\n label: message('Reported'),\n description: message('Show only reported comments'),\n defaultOperator: FilterOperator.has,\n control: {\n type: FilterControlType.BooleanToggle,\n defaultValue: '*',\n },\n },\n {\n key: 'user_id',\n label: message('User'),\n description: message('User comment was created by'),\n defaultOperator: FilterOperator.eq,\n control: {\n type: FilterControlType.SelectModel,\n model: USER_MODEL,\n },\n },\n createdAtFilter({\n description: message('Date comment was created'),\n }),\n updatedAtFilter({\n description: message('Date comment was last updated'),\n }),\n];\n","import React, {useCallback, useMemo, useState} from 'react';\nimport {Trans} from '@common/i18n/trans';\nimport clsx from 'clsx';\nimport {StaticPageTitle} from '@common/seo/static-page-title';\nimport {DataTableHeader} from '@common/datatable/data-table-header';\nimport {useBackendFilterUrlParams} from '@common/datatable/filters/backend-filter-url-params';\nimport {\n GetDatatableDataParams,\n useDatatableData,\n} from '@common/datatable/requests/paginated-resources';\nimport {Comment} from '@common/comments/comment';\nimport {FilterList} from '@common/datatable/filters/filter-list/filter-list';\nimport {SelectedStateDatatableHeader} from '@common/datatable/selected-state-datatable-header';\nimport {AnimatePresence} from 'framer-motion';\nimport {DeleteCommentsButton} from '@common/comments/comments-datatable-page/delete-comments-button';\nimport {CommentDatatableItem} from '@common/comments/comments-datatable-page/comment-datatable-item';\nimport {DataTablePaginationFooter} from '@common/datatable/data-table-pagination-footer';\nimport {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';\nimport publicDiscussionsImage from './public-discussion.svg';\nimport {FullPageLoader} from '@common/ui/progress/full-page-loader';\nimport {Commentable} from '@common/comments/commentable';\nimport {CommentsDatatableFilters} from '@common/comments/comments-datatable-page/comments-datatable-filters';\n\ninterface Props {\n hideTitle?: boolean;\n commentable?: Commentable;\n}\nexport function CommentsDatatablePage({hideTitle, commentable}: Props) {\n const filters = useMemo(() => {\n return CommentsDatatableFilters.filter(\n f => f.key !== 'commentable_id' || !commentable,\n );\n }, [commentable]);\n const {encodedFilters} = useBackendFilterUrlParams(filters);\n const [params, setParams] = useState({perPage: 15});\n const [selectedComments, setSelectedComments] = useState([]);\n const query = useDatatableData(\n 'comment',\n {\n ...params,\n with: 'commentable',\n withCount: 'reports',\n filters: encodedFilters,\n commentable_type: commentable?.model_type,\n commentable_id: commentable?.id,\n },\n undefined,\n () => {\n setSelectedComments([]);\n },\n );\n\n const toggleComment = useCallback(\n (id: number) => {\n const newValues = [...selectedComments];\n if (!newValues.includes(id)) {\n newValues.push(id);\n } else {\n const index = newValues.indexOf(id);\n newValues.splice(index, 1);\n }\n setSelectedComments(newValues);\n },\n [selectedComments, setSelectedComments],\n );\n\n const isFiltering = !!(params.query || params.filters || encodedFilters);\n const pagination = query.data?.pagination;\n\n return (\n
\n
\n \n \n \n {!hideTitle && (\n

\n \n

\n )}\n
\n
\n \n {selectedComments.length ? (\n \n }\n key=\"selected\"\n />\n ) : (\n setParams({...params, query})}\n key=\"default\"\n />\n )}\n \n \n\n {query.isLoading ? (\n \n ) : (\n
\n {pagination?.data.map(comment => (\n toggleComment(comment.id)}\n />\n ))}\n
\n )}\n\n {(query.isFetched || query.isPlaceholderData) &&\n !pagination?.data.length ? (\n }\n filteringTitle={}\n />\n ) : undefined}\n\n setParams({...params, page})}\n onPerPageChange={perPage => setParams({...params, perPage})}\n />\n
\n
\n );\n}\n","export default \"__VITE_ASSET__5161f729__\"","import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';\nimport {Button} from '@common/ui/buttons/button';\nimport {Trans} from '@common/i18n/trans';\nimport {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';\nimport React from 'react';\nimport {ButtonVariant} from '@common/ui/buttons/get-shared-button-style';\nimport {ButtonSize} from '@common/ui/buttons/button-size';\nimport {useDeleteReviews} from '@app/reviews/requests/use-delete-reviews';\n\ninterface Props {\n reviewIds: number[];\n variant?: ButtonVariant;\n size?: ButtonSize;\n}\nexport function DeleteReviewsButton({\n reviewIds,\n variant = 'outline',\n size = 'xs',\n}: Props) {\n const deleteReviews = useDeleteReviews();\n return (\n {\n if (isConfirmed) {\n deleteReviews.mutate({reviewIds});\n }\n }}\n >\n \n \n \n \n }\n body={\n reviewIds.length > 1 ? (\n \n ) : (\n \n )\n }\n confirm={}\n />\n \n );\n}\n","import {BackendResponse} from '@common/http/backend-response/backend-response';\nimport {useMutation} from '@tanstack/react-query';\nimport {apiClient, queryClient} from '@common/http/query-client';\nimport {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';\nimport {Review} from '@app/titles/models/review';\nimport {UseFormReturn} from 'react-hook-form';\nimport {onFormQueryError} from '@common/errors/on-form-query-error';\nimport {CreateReviewPayload} from '@app/reviews/requests/use-create-review';\nimport {toast} from '@common/ui/toast/toast';\nimport {message} from '@common/i18n/message';\n\ninterface Response extends BackendResponse {\n review: Review;\n}\n\nexport function useUpdateReview(\n review: Review,\n form?: UseFormReturn,\n) {\n return useMutation({\n mutationFn: (payload: CreateReviewPayload) => updateReview(review, payload),\n onSuccess: () => {\n queryClient.invalidateQueries({queryKey: ['reviews']});\n toast(message('Review updated'));\n },\n onError: r => (form ? onFormQueryError(r, form) : showHttpErrorToast(r)),\n });\n}\n\nfunction updateReview(\n review: Review,\n payload: CreateReviewPayload,\n): Promise {\n return apiClient\n .put(`reviews/${review.id}`, {\n score: payload.score,\n title: payload.title,\n body: payload.body,\n })\n .then(r => r.data);\n}\n","import {User} from '@common/auth/user';\nimport React, {Fragment, useContext, useState} from 'react';\nimport {Checkbox} from '@common/ui/forms/toggle/checkbox';\nimport {UserAvatar} from '@common/ui/images/user-avatar';\nimport {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';\nimport {queryClient} from '@common/http/query-client';\nimport {Button} from '@common/ui/buttons/button';\nimport {Trans} from '@common/i18n/trans';\nimport {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';\nimport {SiteConfigContext} from '@common/core/settings/site-config-context';\nimport {Link} from 'react-router-dom';\nimport {LinkStyle} from '@common/ui/buttons/external-link';\nimport {NormalizedModel} from '@common/datatable/filters/normalized-model';\nimport {Review} from '@app/titles/models/review';\nimport {TitleRating} from '@app/reviews/title-rating';\nimport {useUpdateReview} from '@app/admin/reviews/requests/use-update-review';\nimport {useForm} from 'react-hook-form';\nimport {CreateReviewPayload} from '@app/reviews/requests/use-create-review';\nimport {Form} from '@common/ui/forms/form';\nimport {StarSelector} from '@app/reviews/review-list/star-selector';\nimport {DeleteReviewsButton} from '@app/admin/reviews/delete-reviews-button';\nimport {BulletSeparatedItems} from '@app/titles/bullet-separated-items';\n\ninterface Props {\n review: Review;\n isSelected: boolean;\n onToggle: () => void;\n}\nexport function ReviewDatatableItem({review, isSelected, onToggle}: Props) {\n const [isEditing, setIsEditing] = useState(false);\n\n const helpfulCount = review.helpful_count || 1;\n const totalFeedbackCount =\n review.helpful_count + review.not_helpful_count || 1;\n\n return (\n
\n {review.reviewable && (\n \n )}\n
\n \n
\n \n {isEditing ? (\n {\n setIsEditing(false);\n if (isSaved) {\n queryClient.invalidateQueries({queryKey: ['comment']});\n }\n }}\n />\n ) : (\n \n
\n \n {review.title && (\n
\n {review.title}\n
\n )}\n
\n {review.body}\n
\n
\n \n \n {review.reports_count ? (\n \n ) : null}\n \n
\n
\n
\n \n setIsEditing(true)}\n >\n \n \n
\n
\n )}\n
\n
\n
\n );\n}\n\ninterface ReviewableHeaderProps {\n isSelected: boolean;\n onToggle: Props['onToggle'];\n reviewable: NormalizedModel;\n}\nfunction ReviewableHeader({\n isSelected,\n onToggle,\n reviewable,\n}: ReviewableHeaderProps) {\n return (\n
\n
\n onToggle()} />\n
\n {reviewable.image && (\n \n )}\n
{reviewable.name}
\n
({reviewable.model_type})
\n
\n );\n}\n\ninterface CommentHeaderProps {\n review: Review;\n}\nfunction ReviewHeader({review}: CommentHeaderProps) {\n return (\n
\n
\n {review.user && (\n \n )}\n
\n
\n \n {review.user && (\n \n )}\n
\n );\n}\n\ninterface EditReviewFormProps {\n review: Review;\n onClose: (saved: boolean) => void;\n}\nfunction EditReviewForm({review, onClose}: EditReviewFormProps) {\n const [content, setContent] = useState(review.body);\n const updateReview = useUpdateReview(review);\n const form = useForm({\n defaultValues: {\n score: review.score,\n title: review.title,\n body: review.body,\n },\n });\n return (\n {\n updateReview.mutate(newValues, {onSuccess: () => onClose(true)});\n }}\n >\n {\n form.setValue('score', newScore);\n }}\n />\n }\n labelSuffix={}\n autoFocus\n minLength={10}\n required\n />\n }\n labelSuffix={}\n inputElementType=\"textarea\"\n rows={5}\n minLength={100}\n required\n />\n \n \n \n onClose(false)}\n disabled={updateReview.isPending}\n >\n \n \n \n );\n}\n\ninterface UserDisplayNameProps {\n user: User;\n show: 'display_name' | 'email';\n}\nfunction UserDisplayName({user, show}: UserDisplayNameProps) {\n const {auth} = useContext(SiteConfigContext);\n if (auth.getUserProfileLink) {\n return (\n \n {user[show]}\n \n );\n }\n return
{user[show]}
;\n}\n","import {\n ALL_PRIMITIVE_OPERATORS,\n BackendFilter,\n FilterControlType,\n FilterOperator,\n} from '@common/datatable/filters/backend-filter';\nimport {message} from '@common/i18n/message';\nimport {USER_MODEL} from '@common/auth/user';\nimport {\n createdAtFilter,\n updatedAtFilter,\n} from '@common/datatable/filters/timestamp-filters';\nimport {TITLE_MODEL} from '@app/titles/models/title';\n\nexport const ReviewsDatatableFilters: BackendFilter[] = [\n {\n key: 'user_id',\n label: message('User'),\n description: message('User review was created by'),\n defaultOperator: FilterOperator.eq,\n control: {\n type: FilterControlType.SelectModel,\n model: USER_MODEL,\n },\n },\n {\n key: 'reviewable_id',\n label: message('Title'),\n description: message('Movie or series review was created for'),\n defaultOperator: FilterOperator.eq,\n extraFilters: [\n {\n key: 'reviewable_type',\n operator: FilterOperator.eq,\n value: 'App\\\\Title',\n },\n ],\n control: {\n type: FilterControlType.SelectModel,\n model: TITLE_MODEL,\n },\n },\n {\n key: 'score',\n label: message('Score'),\n description: message('Review score'),\n defaultOperator: FilterOperator.gte,\n operators: ALL_PRIMITIVE_OPERATORS,\n control: {\n type: FilterControlType.Input,\n inputType: 'number',\n minValue: 1,\n maxValue: 10,\n defaultValue: 7,\n },\n },\n {\n key: 'helpful_count',\n label: message('Helpful count'),\n description: message('How many users found this review helpful'),\n defaultOperator: FilterOperator.gte,\n operators: ALL_PRIMITIVE_OPERATORS,\n control: {\n type: FilterControlType.Input,\n inputType: 'number',\n minValue: 1,\n defaultValue: 10,\n },\n },\n {\n key: 'not_helpful_count',\n label: message('Not helpful count'),\n description: message('How many users found this review not helpful'),\n defaultOperator: FilterOperator.gte,\n operators: ALL_PRIMITIVE_OPERATORS,\n control: {\n type: FilterControlType.Input,\n inputType: 'number',\n minValue: 1,\n defaultValue: 10,\n },\n },\n createdAtFilter({\n description: message('Date review was created'),\n }),\n updatedAtFilter({\n description: message('Date review was last updated'),\n }),\n];\n","import React, { useCallback, useMemo, useState } from \"react\";\nimport { Trans } from \"@common/i18n/trans\";\nimport clsx from \"clsx\";\nimport { StaticPageTitle } from \"@common/seo/static-page-title\";\nimport { DataTableHeader } from \"@common/datatable/data-table-header\";\nimport {\n useBackendFilterUrlParams\n} from \"@common/datatable/filters/backend-filter-url-params\";\nimport {\n GetDatatableDataParams,\n useDatatableData\n} from \"@common/datatable/requests/paginated-resources\";\nimport { FilterList } from \"@common/datatable/filters/filter-list/filter-list\";\nimport {\n SelectedStateDatatableHeader\n} from \"@common/datatable/selected-state-datatable-header\";\nimport { AnimatePresence } from \"framer-motion\";\nimport {\n DataTablePaginationFooter\n} from \"@common/datatable/data-table-pagination-footer\";\nimport {\n DataTableEmptyStateMessage\n} from \"@common/datatable/page/data-table-emty-state-message\";\nimport reviewsImage from \"./reviews.svg\";\nimport { FullPageLoader } from \"@common/ui/progress/full-page-loader\";\nimport { Review } from \"@app/titles/models/review\";\nimport { DeleteReviewsButton } from \"@app/admin/reviews/delete-reviews-button\";\nimport { ReviewDatatableItem } from \"@app/admin/reviews/review-datatable-item\";\nimport {\n ReviewsDatatableFilters\n} from \"@app/admin/reviews/reviews-datatable-filters\";\nimport {\n ReviewListSortButton\n} from \"@app/reviews/review-list/review-list-sort-button\";\nimport { Reviewable } from \"@app/reviews/reviewable\";\n\ninterface Props {\n hideTitle?: boolean;\n reviewable?: Reviewable;\n}\nexport function ReviewsDatatablePage({hideTitle, reviewable}: Props) {\n const filters = useMemo(() => {\n return ReviewsDatatableFilters.filter(\n f => f.key !== 'reviewable_id' || !reviewable,\n );\n }, [reviewable]);\n const {encodedFilters} = useBackendFilterUrlParams(filters);\n const [params, setParams] = useState({perPage: 15});\n const [selectedReviews, setSelectedReviews] = useState([]);\n const [sort, setSort] = useState('created_at:desc');\n const [orderBy, orderDir] = sort.split(':');\n\n const query = useDatatableData('reviews', {\n ...params,\n orderBy,\n orderDir: orderDir as 'asc' | 'desc',\n with: 'reviewable,user',\n filters: encodedFilters,\n reviewable_type: reviewable?.model_type,\n reviewable_id: reviewable?.id,\n }, undefined, () => {\n setSelectedReviews([]);\n });\n\n const toggleReview = useCallback(\n (id: number) => {\n const newValues = [...selectedReviews];\n if (!newValues.includes(id)) {\n newValues.push(id);\n } else {\n const index = newValues.indexOf(id);\n newValues.splice(index, 1);\n }\n setSelectedReviews(newValues);\n },\n [selectedReviews, setSelectedReviews],\n );\n\n const isFiltering = !!(params.query || params.filters || encodedFilters);\n const pagination = query.data?.pagination;\n\n return (\n
\n
\n \n \n \n {!hideTitle && (\n

\n \n

\n )}\n
\n
\n \n {selectedReviews.length ? (\n \n }\n key=\"selected\"\n />\n ) : (\n setParams({...params, query})}\n actions={\n setSort(newSort)}\n color=\"primary\"\n showReportsItem\n />\n }\n />\n )}\n \n \n\n {query.isLoading ? (\n \n ) : (\n
\n {pagination?.data.map(review => (\n toggleReview(review.id)}\n />\n ))}\n
\n )}\n\n {(query.isFetched || query.isPlaceholderData) &&\n !pagination?.data.length ? (\n }\n filteringTitle={}\n />\n ) : undefined}\n\n setParams({...params, page})}\n onPerPageChange={perPage => setParams({...params, perPage})}\n />\n
\n
\n );\n}\n","export default \"__VITE_ASSET__abdf0323__\"","import {CheckIcon} from '@common/icons/material/Check';\nimport {CloseIcon} from '@common/icons/material/Close';\nimport React from 'react';\n\ninterface BooleanIndicatorProps {\n value: boolean;\n}\nexport function BooleanIndicator({value}: BooleanIndicatorProps) {\n if (value) {\n return ;\n }\n return ;\n}\n","import {createSvgIcon} from '../create-svg-icon';\n\nexport const BarChartIcon = createSvgIcon(\n \n, 'BarChartOutlined');\n","import {ColumnConfig} from '@common/datatable/column-config';\nimport {Trans} from '@common/i18n/trans';\nimport {FormattedDate} from '@common/i18n/formatted-date';\nimport {Link} from 'react-router-dom';\nimport {IconButton} from '@common/ui/buttons/icon-button';\nimport {EditIcon} from '@common/icons/material/Edit';\nimport React, {Fragment} from 'react';\nimport {Video} from '@app/titles/models/video';\nimport {BooleanIndicator} from '@common/datatable/column-templates/boolean-indicator';\nimport {FormattedNumber} from '@common/i18n/formatted-number';\nimport {TitlePoster} from '@app/titles/title-poster/title-poster';\nimport {BarChartIcon} from '@common/icons/material/BarChart';\nimport {CompactSeasonEpisode} from '@app/episodes/compact-season-episode';\nimport {getWatchLink} from '@app/videos/watch-page/get-watch-link';\n\nexport const VideosDatatableColumns: ColumnConfig