mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
Merge branch 'beta' into production
This commit is contained in:
commit
c320631fbb
82 changed files with 4991 additions and 375 deletions
|
|
@ -75,6 +75,7 @@
|
|||
"semver": "^7.7.2",
|
||||
"slugify": "^1.6.6",
|
||||
"subsrt-ts": "^2.1.2",
|
||||
"wyzie-lib": "^2.2.5",
|
||||
"zustand": "^4.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@ importers:
|
|||
subsrt-ts:
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.2
|
||||
wyzie-lib:
|
||||
specifier: ^2.2.5
|
||||
version: 2.2.5
|
||||
zustand:
|
||||
specifier: ^4.5.7
|
||||
version: 4.5.7(@types/react@18.3.23)(immer@10.1.1)(react@18.3.1)
|
||||
|
|
|
|||
124
public/notifications.xml
Normal file
124
public/notifications.xml
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>P-Stream Notifications</title>
|
||||
<link>https://pstream.mov</link>
|
||||
<description>Site updates and important notifications for P-Stream users</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>Mon, 28 Jul 2025 21:53:00 GMT</lastBuildDate>
|
||||
<atom:link href="https://pstream.mov/notifications.xml" rel="self" type="application/rss+xml" />
|
||||
|
||||
<item>
|
||||
<guid>notification-8-1-25</guid>
|
||||
<title>Welcome to the P-Stream Beta!</title>
|
||||
<description>P-Stream is now in beta! This is a test of the new notification system.
|
||||
|
||||
You can now receive notifications for new updates and important announcements.
|
||||
|
||||
You can also view the notifications in the notifications page.</description>
|
||||
<pubDate>Fri, 01 Aug 2025 21:00:00 GMT</pubDate>
|
||||
<category>announcement</category>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<guid>notification-028</guid>
|
||||
<title>P-Stream v5.0.4 released!</title>
|
||||
<description>A bunch more quality-of-life improvements and fixes have been released.
|
||||
- Accounts are no longer required for migration downloads and uploads.
|
||||
- Right-click to open the details modal has been re-added.
|
||||
- Increased hold-to-edit time for media cards.
|
||||
- Re-added jiggle physics while editing.
|
||||
- Custom passphrase support.
|
||||
- Improved subtitle selection experience.
|
||||
- Bug fixes and behind-the-scenes changes.
|
||||
|
||||
A few new sources have also been added, including Flixer and VidSrc.vip.</description>
|
||||
<pubDate>Tue, 15 Jul 2025 20:58:00 GMT</pubDate>
|
||||
<category>update</category>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<guid>notification-026</guid>
|
||||
<title>New Domain: pstream.mov</title>
|
||||
<link>https://pstream.mov</link>
|
||||
<description>The new domain is https://pstream.mov/
|
||||
|
||||
Most pages should now be working. If you had an account on the old site, your data will be there. If you did not have an account, you can download your data from pstream.org and upload it to the new site.</description>
|
||||
<pubDate>Mon, 14 Jul 2025 17:30:00 GMT</pubDate>
|
||||
<category>announcement</category>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<guid>notification-022</guid>
|
||||
<title>P-Stream v5.0.2 released!</title>
|
||||
<description>This is another minor update with great quality-of-life features.
|
||||
- Added "open in..." button to the download menu.
|
||||
- Added the ability to set a custom playback speed.
|
||||
- Added buttons to mark episodes as watched or unwatched.
|
||||
- Added custom color pickers to account styles and more user icons.
|
||||
- Estimate quality for non-standard resolution sources.
|
||||
- Re-added email to the DMCA page.
|
||||
- Added an autoplay toggle to playback settings.
|
||||
- Fixed end time to account for playback speed.
|
||||
- Updated some translations and fixed many bugs.</description>
|
||||
<pubDate>Mon, 07 Jul 2025 19:55:00 GMT</pubDate>
|
||||
<category>update</category>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<guid>notification-008</guid>
|
||||
<title>P-Stream v5.0.1</title>
|
||||
<description>Some minor updates this time:
|
||||
- Added network images to the details modal.
|
||||
- Added a letterboxd/trakt list page: "Discover All Lists."
|
||||
- Fixed audio description not displaying.
|
||||
- Automatically open the settings menu when failing to play.
|
||||
- Added a compact episodes view.
|
||||
- Updated the "About" page.
|
||||
- Added low-performance/bandwidth mode.
|
||||
- The share button on the details modal now opens the iOS share sheet.
|
||||
- Fixed various bugs.</description>
|
||||
<pubDate>Tue, 24 Jun 2025 18:20:00 GMT</pubDate>
|
||||
<category>update</category>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<guid>notification-003-2</guid>
|
||||
<title>New Movie Lists Page</title>
|
||||
<link>https://pstream.mov/discover/all</link>
|
||||
<description>A new page has been added to discover many movie lists powered by Letterboxd. A couple of minor bugs were also fixed.</description>
|
||||
<pubDate>Sat, 07 Jun 2025 18:41:00 GMT</pubDate>
|
||||
<category>update</category>
|
||||
</item>
|
||||
|
||||
<item>
|
||||
<guid>notification-005-2</guid>
|
||||
<title>5.0.0 Released!</title>
|
||||
<description>This release is the largest facelift to the project yet, with many new features and fixes.
|
||||
|
||||
New Features
|
||||
- Discover Page Overhaul: The Discover page has been completely rewritten.
|
||||
- New Trakt Sections: Explore new "Latest Releases" and "Top 4K" sections.
|
||||
- Featured Movies/Shows Carousel: A new slideshow of new and popular movies or shows is now on the Discover page.
|
||||
- Quality Indicators: See the video quality (HD or CAM) on the Details Modal or Featured Carousel.
|
||||
- Expanded Descriptions: Episode descriptions can now be expanded.
|
||||
- Watchparty (Beta)! Easily invite anyone to watch with you. Now joinable from the home page.
|
||||
- You can now click on actors and directors on a movie or show's detail page to explore their other work.
|
||||
|
||||
Improvements
|
||||
- (Most) User preferences now sync.
|
||||
- UI & UX Refresh: New player settings design and an overhauled Details Modal.
|
||||
- You can now toggle Carousel View to show "Currently Watching" and "Bookmarked" lists as carousels.
|
||||
|
||||
Bug Fixes
|
||||
- Fixed several issues with loading skeletons.
|
||||
- Implemented a fix to better handle subtitle files.
|
||||
- Fixed various layout issues.
|
||||
- Febbox Token now actually syncs with your account.
|
||||
- Many other bugs.</description>
|
||||
<pubDate>Fri, 06 Jun 2025 11:29:00 GMT</pubDate>
|
||||
<category>update</category>
|
||||
</item>
|
||||
|
||||
</channel>
|
||||
</rss>
|
||||
|
|
@ -141,7 +141,8 @@
|
|||
"actions": {
|
||||
"copied": "Copied",
|
||||
"copy": "Copy",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"auth": {
|
||||
"createAccount": "Don't have an account yet 😬 <0>Create an account.</0>",
|
||||
|
|
@ -240,7 +241,25 @@
|
|||
},
|
||||
"home": {
|
||||
"bookmarks": {
|
||||
"sectionTitle": "Bookmarks"
|
||||
"sectionTitle": "Bookmarks",
|
||||
"showAll": "Show all",
|
||||
"groups": {
|
||||
"dropdown": {
|
||||
"placeholderButton": "Add to group",
|
||||
"empty": "No groups yet",
|
||||
"addButton": "Add",
|
||||
"removeFromGroup": "Remove from group",
|
||||
"removeAll": "Remove all"
|
||||
},
|
||||
"reorder": {
|
||||
"button": "Reorder",
|
||||
"done": "Done",
|
||||
"title": "Edit Group Order",
|
||||
"description": "Drag and drop to reorder your bookmark groups",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save"
|
||||
}
|
||||
}
|
||||
},
|
||||
"continueWatching": {
|
||||
"sectionTitle": "Continue Watching..."
|
||||
|
|
@ -316,7 +335,9 @@
|
|||
"show": "Show"
|
||||
},
|
||||
"episodeShort": "E",
|
||||
"seasonShort": "S"
|
||||
"seasonShort": "S",
|
||||
"seasonWatched": "Are you sure you want to mark the season as watched?",
|
||||
"seasonUnwatched": "Are you sure you want to mark the season as unwatched?"
|
||||
},
|
||||
"details": {
|
||||
"resume": "Resume",
|
||||
|
|
@ -614,7 +635,9 @@
|
|||
"playback": {
|
||||
"speedLabel": "Playback speed",
|
||||
"title": "Playback settings",
|
||||
"disabled": "(Disabled in watch party)"
|
||||
"disabled": "(Disabled in watch party)",
|
||||
"speedBoosted": "Playback speed boosted to 2x",
|
||||
"speedUnboosted": "Playback speed reset to {{speed}}x"
|
||||
},
|
||||
"quality": {
|
||||
"automaticLabel": "Automatic quality",
|
||||
|
|
@ -667,6 +690,8 @@
|
|||
"unknownLanguage": "Unknown",
|
||||
"dropSubtitleFile": "Drop subtitle file here! >_<",
|
||||
"scrapeButton": "Scrape subtitles",
|
||||
"refresh": "Refresh External Subtitles",
|
||||
"refreshing": "Refreshing...",
|
||||
"empty": "There are no provided subtitles for this.",
|
||||
"notFound": "None of the available options match your query",
|
||||
"useNativeSubtitles": "Use native video subtitles",
|
||||
|
|
@ -724,6 +749,8 @@
|
|||
"errorNetwork": "Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available.",
|
||||
"errorNotSupported": "The media or media provider object is not supported."
|
||||
},
|
||||
"copyDebugInfo": "Copy debug info",
|
||||
"debugInfo": "Check console for more details.",
|
||||
"homeButton": "Go home",
|
||||
"text": "There was an error trying to play the media 😖. Please try again or try a different source!",
|
||||
"title": "Failed to play video!"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface BookmarkMetaInput {
|
|||
export interface BookmarkInput {
|
||||
tmdbId: string;
|
||||
meta: BookmarkMetaInput;
|
||||
group?: string[];
|
||||
}
|
||||
|
||||
export function bookmarkMediaToInput(
|
||||
|
|
@ -29,6 +30,7 @@ export function bookmarkMediaToInput(
|
|||
year: item.year ?? 0,
|
||||
},
|
||||
tmdbId,
|
||||
group: item.group,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,15 @@ async function seedFromMnemonic(mnemonic: string) {
|
|||
}
|
||||
|
||||
export function verifyValidMnemonic(mnemonic: string) {
|
||||
return validateMnemonic(mnemonic, wordlist);
|
||||
// First try to validate as BIP39 mnemonic
|
||||
if (validateMnemonic(mnemonic, wordlist)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not a valid BIP39 mnemonic, check if it's a valid custom passphrase
|
||||
const validPassphraseRegex =
|
||||
/^[a-zA-Z0-9\s\-_.,!?@#$%^&*()+=:;"'<>[\]{}|\\/`~]+$/;
|
||||
return mnemonic.length >= 8 && validPassphraseRegex.test(mnemonic);
|
||||
}
|
||||
|
||||
export async function keysFromSeed(seed: Uint8Array): Promise<Keys> {
|
||||
|
|
|
|||
29
src/backend/accounts/groupOrder.ts
Normal file
29
src/backend/accounts/groupOrder.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { ofetch } from "ofetch";
|
||||
|
||||
import { getAuthHeaders } from "@/backend/accounts/auth";
|
||||
import { AccountWithToken } from "@/stores/auth";
|
||||
|
||||
export interface GroupOrderResponse {
|
||||
groupOrder: string[];
|
||||
}
|
||||
|
||||
export function updateGroupOrder(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
groupOrder: string[],
|
||||
) {
|
||||
return ofetch<GroupOrderResponse>(`/users/${account.userId}/group-order`, {
|
||||
method: "PUT",
|
||||
body: groupOrder,
|
||||
baseURL: url,
|
||||
headers: getAuthHeaders(account.token),
|
||||
});
|
||||
}
|
||||
|
||||
export function getGroupOrder(url: string, account: AccountWithToken) {
|
||||
return ofetch<GroupOrderResponse>(`/users/${account.userId}/group-order`, {
|
||||
method: "GET",
|
||||
baseURL: url,
|
||||
headers: getAuthHeaders(account.token),
|
||||
});
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ export interface BookmarkResponse {
|
|||
poster?: string;
|
||||
type: "show" | "movie";
|
||||
};
|
||||
group: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +63,7 @@ export function bookmarkResponsesToEntries(responses: BookmarkResponse[]) {
|
|||
const entries = responses.map((bookmark) => {
|
||||
const item: BookmarkMediaItem = {
|
||||
...bookmark.meta,
|
||||
group: bookmark.group.length > 0 ? bookmark.group : undefined,
|
||||
updatedAt: new Date(bookmark.updatedAt).getTime(),
|
||||
};
|
||||
return [bookmark.tmdbId, item] as const;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { list } from "subsrt-ts";
|
||||
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
import { convertSubtitlesToSrt } from "@/components/player/utils/captions";
|
||||
import {
|
||||
convertSubtitlesToSrt,
|
||||
fixUTF8Encoding,
|
||||
} from "@/components/player/utils/captions";
|
||||
import { CaptionListItem } from "@/stores/player/slices/source";
|
||||
import { SimpleCache } from "@/utils/cache";
|
||||
|
||||
|
|
@ -62,13 +65,14 @@ export async function downloadCaption(
|
|||
}
|
||||
if (!data) throw new Error("failed to get caption data");
|
||||
|
||||
// Ensure the data is in UTF-8
|
||||
// Ensure the data is in UTF-8 and fix any encoding issues
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const utf8Bytes = encoder.encode(data);
|
||||
const utf8Data = decoder.decode(utf8Bytes);
|
||||
const fixedData = fixUTF8Encoding(utf8Data);
|
||||
|
||||
const output = convertSubtitlesToSrt(utf8Data);
|
||||
const output = convertSubtitlesToSrt(fixedData);
|
||||
downloadCache.set(caption.url, output, expirySeconds);
|
||||
return output;
|
||||
}
|
||||
|
|
@ -93,11 +97,12 @@ export async function downloadWebVTT(url: string): Promise<string> {
|
|||
const decoder = new TextDecoder(charset);
|
||||
const data = decoder.decode(buffer);
|
||||
|
||||
// Ensure the data is in UTF-8
|
||||
// Ensure the data is in UTF-8 and fix any encoding issues
|
||||
const encoder = new TextEncoder();
|
||||
const utf8Bytes = encoder.encode(data);
|
||||
const utf8Data = decoder.decode(utf8Bytes);
|
||||
const fixedData = fixUTF8Encoding(utf8Data);
|
||||
|
||||
downloadCache.set(url, utf8Data, expirySeconds);
|
||||
return utf8Data;
|
||||
downloadCache.set(url, fixedData, expirySeconds);
|
||||
return fixedData;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ export enum Icons {
|
|||
TMDB = "tmdb",
|
||||
IMDB = "imdb",
|
||||
EAR = "ear",
|
||||
BELL = "bell",
|
||||
RELOAD = "reload",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
|
|
@ -175,6 +177,8 @@ const iconList: Record<Icons, string> = {
|
|||
tmdb: `<svg width="2em" height="2em" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 190.24 81.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M105.67,36.06h66.9A17.67,17.67,0,0,0,190.24,18.4h0A17.67,17.67,0,0,0,172.57.73h-66.9A17.67,17.67,0,0,0,88,18.4h0A17.67,17.67,0,0,0,105.67,36.06Zm-88,45h76.9A17.67,17.67,0,0,0,112.24,63.4h0A17.67,17.67,0,0,0,94.57,45.73H17.67A17.67,17.67,0,0,0,0,63.4H0A17.67,17.67,0,0,0,17.67,81.06ZM10.41,35.42h7.8V6.92h10.1V0H.31v6.9h10.1Zm28.1,0h7.8V8.25h.1l9,27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2,23.1h-.1L50.31,0H38.51ZM152.43,55.67a15.07,15.07,0,0,0-4.52-5.52,18.57,18.57,0,0,0-6.68-3.08,33.54,33.54,0,0,0-8.07-1h-11.7v35.4h12.75a24.58,24.58,0,0,0,7.55-1.15A19.34,19.34,0,0,0,148.11,77a16.27,16.27,0,0,0,4.37-5.5,16.91,16.91,0,0,0,1.63-7.58A18.5,18.5,0,0,0,152.43,55.67ZM145,68.6A8.8,8.8,0,0,1,142.36,72a10.7,10.7,0,0,1-4,1.82,21.57,21.57,0,0,1-5,.55h-4.05v-21h4.6a17,17,0,0,1,4.67.63,11.66,11.66,0,0,1,3.88,1.87A9.14,9.14,0,0,1,145,59a9.87,9.87,0,0,1,1,4.52A11.89,11.89,0,0,1,145,68.6Zm44.63-.13a8,8,0,0,0-1.58-2.62A8.38,8.38,0,0,0,185.63,64a10.31,10.31,0,0,0-3.17-1v-.1a9.22,9.22,0,0,0,4.42-2.82,7.43,7.43,0,0,0,1.68-5,8.42,8.42,0,0,0-1.15-4.65,8.09,8.09,0,0,0-3-2.72,12.56,12.56,0,0,0-4.18-1.3,32.84,32.84,0,0,0-4.62-.33h-13.2v35.4h14.5a22.41,22.41,0,0,0,4.72-.5,13.53,13.53,0,0,0,4.28-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68A9.39,9.39,0,0,0,189.66,68.47ZM170.21,52.72h5.3a10,10,0,0,1,1.85.18,6.18,6.18,0,0,1,1.7.57,3.39,3.39,0,0,1,1.22,1.13,3.22,3.22,0,0,1,.48,1.82,3.63,3.63,0,0,1-.43,1.8,3.4,3.4,0,0,1-1.12,1.2,4.92,4.92,0,0,1-1.58.65,7.51,7.51,0,0,1-1.77.2h-5.65Zm11.72,20a3.9,3.9,0,0,1-1.22,1.3,4.64,4.64,0,0,1-1.68.7,8.18,8.18,0,0,1-1.82.2h-7v-8h5.9a15.35,15.35,0,0,1,2,.15,8.47,8.47,0,0,1,2.05.55,4,4,0,0,1,1.57,1.18,3.11,3.11,0,0,1,.63,2A3.71,3.71,0,0,1,181.93,72.72Z"/></g></g></svg>`,
|
||||
imdb: `<svg width="2em" height="2em" fill="currentColor" viewBox="0 0 32 32" id="Camada_1" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g> <path d="M8.4,21.1H5.9V9.9h3.8l0.7,4.7h0.1L11,9.9h3.8v11.2h-2.5v-6.7h-0.1l-0.9,6.7H9.4l-1-6.7h0L8.4,21.1L8.4,21.1z"></path> <path d="M15.8,9.8c0.4,0,3.2-0.1,4.7,0.1c1.2,0.1,1.8,1.1,1.9,2.3c0.1,2.2,0.1,4.4,0.1,6.6c0,0.2,0,0.5-0.1,0.8 c-0.2,0.9-0.7,1.4-1.9,1.5c-1.5,0.1-3,0.1-4.4,0.1c0,0-0.1,0-0.2,0V9.8z M18.8,11.9v7.2c0.5,0,0.8-0.2,0.8-0.7c0-1.9,0-3.9,0-5.9 C19.6,12,19.4,11.8,18.8,11.9z"></path> <path d="M2,21.1V9.9h2.9v11.2H2z"></path> <path d="M29.9,14.1c-0.1-0.8-0.6-1.2-1.4-1.4c-0.8-0.1-1.6,0-2.3,0.7V9.9h-2.8v11.2H26c0.1-0.2,0.1-0.4,0.2-0.5c0,0,0,0,0.1,0 c0.1,0.1,0.2,0.2,0.3,0.3c0.7,0.5,1.5,0.6,2.3,0.3c0.7-0.3,1-0.9,1-1.6c0-0.8,0.1-1.7,0.1-2.6C30,16,30,15,29.9,14.1L29.9,14.1z M27.1,19.1c0,0.2-0.2,0.4-0.4,0.4s-0.4-0.2-0.4-0.4v-4.3c0-0.2,0.2-0.4,0.4-0.4s0.4,0.2,0.4,0.4V19.1z"></path> </g> </g></svg>`,
|
||||
ear: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-ear" viewBox="0 0 16 16"> <path d="M8.5 0A5.5 5.5 0 0 0 3 5.5v7.047a3.453 3.453 0 0 0 6.687 1.212l.51-1.363a4.6 4.6 0 0 1 .67-1.197l2.008-2.581A5.34 5.34 0 0 0 8.66 0zM7 5.5v2.695q.168-.09.332-.192c.327-.208.577-.44.72-.727a.5.5 0 1 1 .895.448c-.256.513-.673.865-1.079 1.123A9 9 0 0 1 7 9.313V11.5a.5.5 0 0 1-1 0v-6a2.5 2.5 0 0 1 5 0V6a.5.5 0 0 1-1 0v-.5a1.5 1.5 0 1 0-3 0"/></svg>`,
|
||||
bell: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 640" fill="currentColor"><!--!Font Awesome Free v7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M320 64C302.3 64 288 78.3 288 96L288 99.2C215 114 160 178.6 160 256L160 277.7C160 325.8 143.6 372.5 113.6 410.1L103.8 422.3C98.7 428.6 96 436.4 96 444.5C96 464.1 111.9 480 131.5 480L508.4 480C528 480 543.9 464.1 543.9 444.5C543.9 436.4 541.2 428.6 536.1 422.3L526.3 410.1C496.4 372.5 480 325.8 480 277.7L480 256C480 178.6 425 114 352 99.2L352 96C352 78.3 337.7 64 320 64zM258 528C265.1 555.6 290.2 576 320 576C349.8 576 374.9 555.6 382 528L258 528z"/></svg>`,
|
||||
reload: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 640" fill="currentColor"><!--!Font Awesome Free v7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M544.1 256L552 256C565.3 256 576 245.3 576 232L576 88C576 78.3 570.2 69.5 561.2 65.8C552.2 62.1 541.9 64.2 535 71L483.3 122.8C439 86.1 382 64 320 64C191 64 84.3 159.4 66.6 283.5C64.1 301 76.2 317.2 93.7 319.7C111.2 322.2 127.4 310 129.9 292.6C143.2 199.5 223.3 128 320 128C364.4 128 405.2 143 437.7 168.3L391 215C384.1 221.9 382.1 232.2 385.8 241.2C389.5 250.2 398.3 256 408 256L544.1 256zM573.5 356.5C576 339 563.8 322.8 546.4 320.3C529 317.8 512.7 330 510.2 347.4C496.9 440.4 416.8 511.9 320.1 511.9C275.7 511.9 234.9 496.9 202.4 471.6L249 425C255.9 418.1 257.9 407.8 254.2 398.8C250.5 389.8 241.7 384 232 384L88 384C74.7 384 64 394.7 64 408L64 552C64 561.7 69.8 570.5 78.8 574.2C87.8 577.9 98.1 575.8 105 569L156.8 517.2C201 553.9 258 576 320 576C449 576 555.7 480.6 573.4 356.5z"/></svg>`,
|
||||
};
|
||||
|
||||
function ChromeCastButton() {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Icon, Icons } from "@/components/Icon";
|
|||
export enum UserIcons {
|
||||
CAT = "cat",
|
||||
WEED = "weed",
|
||||
USER_GROUP = "userGroup",
|
||||
USER_GROUP = "user_group",
|
||||
COUCH = "couch",
|
||||
MOBILE = "mobile",
|
||||
TICKET = "ticket",
|
||||
|
|
@ -20,7 +20,8 @@ export enum UserIcons {
|
|||
RISING_STAR = "rising_star",
|
||||
CLOUD_ARROW_UP = "cloud_arrow_up",
|
||||
WAND = "wand",
|
||||
CLAPPER_BOARD = "clapperBoard",
|
||||
CLAPPER_BOARD = "clapper_board",
|
||||
BOOKMARK = "bookmark",
|
||||
}
|
||||
|
||||
export interface UserIconProps {
|
||||
|
|
@ -31,22 +32,23 @@ export interface UserIconProps {
|
|||
const iconList: Record<UserIcons, string> = {
|
||||
cat: `<svg xmlns="http://www.w3.org/2000/svg" height="1.2em" width="1.2em" viewBox="0 0 576 512"><path opacity="1" fill="currentColor" d="M320 192h17.1c22.1 38.3 63.5 64 110.9 64c11 0 21.8-1.4 32-4v4 32V480c0 17.7-14.3 32-32 32s-32-14.3-32-32V339.2L280 448h56c17.7 0 32 14.3 32 32s-14.3 32-32 32H192c-53 0-96-43-96-96V192.5c0-16.1-12-29.8-28-31.8l-7.9-1c-17.5-2.2-30-18.2-27.8-35.7s18.2-30 35.7-27.8l7.9 1c48 6 84.1 46.8 84.1 95.3v85.3c34.4-51.7 93.2-85.8 160-85.8zm160 26.5v0c-10 3.5-20.8 5.5-32 5.5c-28.4 0-54-12.4-71.6-32h0c-3.7-4.1-7-8.5-9.9-13.2C357.3 164 352 146.6 352 128v0V32 12 10.7C352 4.8 356.7 .1 362.6 0h.2c3.3 0 6.4 1.6 8.4 4.2l0 .1L384 21.3l27.2 36.3L416 64h64l4.8-6.4L512 21.3 524.8 4.3l0-.1c2-2.6 5.1-4.2 8.4-4.2h.2C539.3 .1 544 4.8 544 10.7V12 32v96c0 17.3-4.6 33.6-12.6 47.6c-11.3 19.8-29.6 35.2-51.4 42.9zM432 128a16 16 0 1 0 -32 0 16 16 0 1 0 32 0zm48 16a16 16 0 1 0 0-32 16 16 0 1 0 0 32z"/></svg>`,
|
||||
weed: `<svg xmlns="http://www.w3.org/2000/svg" height="1.18em" width="1.18em" viewBox="0 0 500 512"><path opacity="1" fill="currentColor" d="M256 0c5.3 0 10.3 2.7 13.3 7.1c15.8 23.5 36.7 63.7 49.2 109c7.2 26.4 11.8 55.2 10.4 84c11.5-8.8 23.7-16.7 35.8-23.6c41-23.3 84.4-36.9 112.2-42.5c5.2-1 10.7 .6 14.4 4.4s5.4 9.2 4.4 14.5c-5.6 27.7-19.3 70.9-42.7 111.7c-9.1 15.9-19.9 31.7-32.4 46.3c27.8 6.6 52.4 17.3 67.2 25.5c5.1 2.8 8.2 8.2 8.2 14s-3.2 11.2-8.2 14c-15.2 8.4-40.9 19.5-69.8 26.1c-20.2 4.6-42.9 7.2-65.2 4.6l8.3 33.1c1.5 6.1-.6 12.4-5.5 16.4s-11.6 4.6-17.2 1.9L280 417.2V488c0 13.3-10.7 24-24 24s-24-10.7-24-24V417.2l-58.5 29.1c-5.6 2.8-12.3 2.1-17.2-1.9s-7-10.3-5.5-16.4l8.3-33.1c-22.2 2.6-45 0-65.2-4.6c-28.9-6.6-54.6-17.6-69.8-26.1c-5.1-2.8-8.2-8.2-8.2-14s3.2-11.2 8.2-14c14.8-8.2 39.4-18.8 67.2-25.5C78.9 296.3 68.1 280.5 59 264.6c-23.4-40.8-37.1-84-42.7-111.7c-1.1-5.2 .6-10.7 4.4-14.5s9.2-5.4 14.4-4.4c27.9 5.5 71.2 19.2 112.2 42.5c12.1 6.9 24.3 14.7 35.8 23.6c-1.4-28.7 3.1-57.6 10.4-84c12.5-45.3 33.4-85.5 49.2-109c3-4.4 8-7.1 13.3-7.1z"/></svg>`,
|
||||
userGroup: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 640 512"><path opacity="1" fill="currentColor" d="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304h91.4C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3zM609.3 512H471.4c5.4-9.4 8.6-20.3 8.6-32v-8c0-60.7-27.1-115.2-69.8-151.8c2.4-.1 4.7-.2 7.1-.2h61.4C567.8 320 640 392.2 640 481.3c0 17-13.8 30.7-30.7 30.7zM432 256c-31 0-59-12.6-79.3-32.9C372.4 196.5 384 163.6 384 128c0-26.8-6.6-52.1-18.3-74.3C384.3 40.1 407.2 32 432 32c61.9 0 112 50.1 112 112s-50.1 112-112 112z"/></svg>`,
|
||||
user_group: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 640 512"><path opacity="1" fill="currentColor" d="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304h91.4C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3zM609.3 512H471.4c5.4-9.4 8.6-20.3 8.6-32v-8c0-60.7-27.1-115.2-69.8-151.8c2.4-.1 4.7-.2 7.1-.2h61.4C567.8 320 640 392.2 640 481.3c0 17-13.8 30.7-30.7 30.7zM432 256c-31 0-59-12.6-79.3-32.9C372.4 196.5 384 163.6 384 128c0-26.8-6.6-52.1-18.3-74.3C384.3 40.1 407.2 32 432 32c61.9 0 112 50.1 112 112s-50.1 112-112 112z"/></svg>`,
|
||||
couch: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 640 512"><path opacity="1" fill="currentColor" d="M64 160C64 89.3 121.3 32 192 32H448c70.7 0 128 57.3 128 128v33.6c-36.5 7.4-64 39.7-64 78.4v48H128V272c0-38.7-27.5-71-64-78.4V160zM544 272c0-20.9 13.4-38.7 32-45.3c5-1.8 10.4-2.7 16-2.7c26.5 0 48 21.5 48 48V448c0 17.7-14.3 32-32 32H576c-17.7 0-32-14.3-32-32H96c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32V272c0-26.5 21.5-48 48-48c5.6 0 11 1 16 2.7c18.6 6.6 32 24.4 32 45.3v48 32h32H512h32V320 272z"/></svg>`,
|
||||
mobile: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 384 512"><path opacity="1" fill="currentColor" d="M16 64C16 28.7 44.7 0 80 0H304c35.3 0 64 28.7 64 64V448c0 35.3-28.7 64-64 64H80c-35.3 0-64-28.7-64-64V64zM144 448c0 8.8 7.2 16 16 16h64c8.8 0 16-7.2 16-16s-7.2-16-16-16H160c-8.8 0-16 7.2-16 16zM304 64H80V384H304V64z"/></svg>`,
|
||||
ticket: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 576 512"><path opacity="1" fill="currentColor" d="M64 64C28.7 64 0 92.7 0 128v64c0 8.8 7.4 15.7 15.7 18.6C34.5 217.1 48 235 48 256s-13.5 38.9-32.3 45.4C7.4 304.3 0 311.2 0 320v64c0 35.3 28.7 64 64 64H512c35.3 0 64-28.7 64-64V320c0-8.8-7.4-15.7-15.7-18.6C541.5 294.9 528 277 528 256s13.5-38.9 32.3-45.4c8.3-2.9 15.7-9.8 15.7-18.6V128c0-35.3-28.7-64-64-64H64zm64 112l0 160c0 8.8 7.2 16 16 16H432c8.8 0 16-7.2 16-16V176c0-8.8-7.2-16-16-16H144c-8.8 0-16 7.2-16 16zM96 160c0-17.7 14.3-32 32-32H448c17.7 0 32 14.3 32 32V352c0 17.7-14.3 32-32 32H128c-17.7 0-32-14.3-32-32V160z"/></svg>`,
|
||||
saturn: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-planet"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18.816 13.58c2.292 2.138 3.546 4 3.092 4.9c-.745 1.46 -5.783 -.259 -11.255 -3.838c-5.47 -3.579 -9.304 -7.664 -8.56 -9.123c.464 -.91 2.926 -.444 5.803 .805" /><path d="M12 12m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" /></svg>`,
|
||||
headphones: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-headphones"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M21 18a3 3 0 0 1 -2.824 2.995l-.176 .005h-1a3 3 0 0 1 -2.995 -2.824l-.005 -.176v-3a3 3 0 0 1 2.824 -2.995l.176 -.005h1c.351 0 .688 .06 1 .171v-.171a7 7 0 0 0 -13.996 -.24l-.004 .24v.17c.25 -.088 .516 -.144 .791 -.163l.209 -.007h1a3 3 0 0 1 2.995 2.824l.005 .176v3a3 3 0 0 1 -2.824 2.995l-.176 .005h-1a3 3 0 0 1 -2.995 -2.824l-.005 -.176v-6a9 9 0 0 1 17.996 -.265l.004 .265v6z" /></svg>`,
|
||||
tv: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-device-tv"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8.707 2.293l3.293 3.292l3.293 -3.292a1 1 0 0 1 1.32 -.083l.094 .083a1 1 0 0 1 0 1.414l-2.293 2.293h4.586a3 3 0 0 1 3 3v9a3 3 0 0 1 -3 3h-14a3 3 0 0 1 -3 -3v-9a3 3 0 0 1 3 -3h4.585l-2.292 -2.293a1 1 0 0 1 1.414 -1.414" /></svg>`,
|
||||
ghost: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-ghost-2"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 1.999l.041 .002l.208 .003a8 8 0 0 1 7.747 7.747l.003 .248l.177 .006a3 3 0 0 1 2.819 2.819l.005 .176a3 3 0 0 1 -3 3l-.001 1.696l1.833 2.75a1 1 0 0 1 -.72 1.548l-.112 .006h-10c-3.445 .002 -6.327 -2.49 -6.901 -5.824l-.028 -.178l-.071 .001a3 3 0 0 1 -2.995 -2.824l-.005 -.175a3 3 0 0 1 3 -3l.004 -.25a8 8 0 0 1 7.996 -7.75zm0 10.001a2 2 0 0 0 -2 2a1 1 0 0 0 1 1h2a1 1 0 0 0 1 -1a2 2 0 0 0 -2 -2zm-1.99 -4l-.127 .007a1 1 0 0 0 .117 1.993l.127 -.007a1 1 0 0 0 -.117 -1.993zm4 0l-.127 .007a1 1 0 0 0 .117 1.993l.127 -.007a1 1 0 0 0 -.117 -1.993z" /></svg>`,
|
||||
ghost: `<svg xmlns="http://www.w3.org/2000/svg" width="1.11em" height="1.11em" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-ghost-2"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 1.999l.041 .002l.208 .003a8 8 0 0 1 7.747 7.747l.003 .248l.177 .006a3 3 0 0 1 2.819 2.819l.005 .176a3 3 0 0 1 -3 3l-.001 1.696l1.833 2.75a1 1 0 0 1 -.72 1.548l-.112 .006h-10c-3.445 .002 -6.327 -2.49 -6.901 -5.824l-.028 -.178l-.071 .001a3 3 0 0 1 -2.995 -2.824l-.005 -.175a3 3 0 0 1 3 -3l.004 -.25a8 8 0 0 1 7.996 -7.75zm0 10.001a2 2 0 0 0 -2 2a1 1 0 0 0 1 1h2a1 1 0 0 0 1 -1a2 2 0 0 0 -2 -2zm-1.99 -4l-.127 .007a1 1 0 0 0 .117 1.993l.127 -.007a1 1 0 0 0 -.117 -1.993zm4 0l-.127 .007a1 1 0 0 0 .117 1.993l.127 -.007a1 1 0 0 0 -.117 -1.993z" /></svg>`,
|
||||
coffee: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-mug"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3.903 4.008l.183 -.008h10.828a2.08 2.08 0 0 1 2.086 2.077v.923h1.5c1.917 0 3.5 1.477 3.5 3.333v2.334c0 1.856 -1.583 3.333 -3.5 3.333h-1.663a5.33 5.33 0 0 1 -5.17 4h-4.334c-2.944 0 -5.333 -2.375 -5.333 -5.308v-8.618a2.08 2.08 0 0 1 1.903 -2.066m13.097 9.992h1.5c.843 0 1.5 -.613 1.5 -1.333v-2.334c0 -.72 -.657 -1.333 -1.5 -1.333h-1.5z" /></svg>`,
|
||||
fire: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-flame"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 2c0 -.88 1.056 -1.331 1.692 -.722c1.958 1.876 3.096 5.995 1.75 9.12l-.08 .174l.012 .003c.625 .133 1.203 -.43 2.303 -2.173l.14 -.224a1 1 0 0 1 1.582 -.153c1.334 1.435 2.601 4.377 2.601 6.27c0 4.265 -3.591 7.705 -8 7.705s-8 -3.44 -8 -7.706c0 -2.252 1.022 -4.716 2.632 -6.301l.605 -.589c.241 -.236 .434 -.43 .618 -.624c1.43 -1.512 2.145 -2.924 2.145 -4.78" /></svg>`,
|
||||
megaphone: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-megaphone-fill" viewBox="0 0 16 16"><path d="M13 2.5a1.5 1.5 0 0 1 3 0v11a1.5 1.5 0 0 1-3 0zm-1 .724c-2.067.95-4.539 1.481-7 1.656v6.237a25 25 0 0 1 1.088.085c2.053.204 4.038.668 5.912 1.56zm-8 7.841V4.934c-.68.027-1.399.043-2.008.053A2.02 2.02 0 0 0 0 7v2c0 1.106.896 1.996 1.994 2.009l.496.008a64 64 0 0 1 1.51.048m1.39 1.081q.428.032.85.078l.253 1.69a1 1 0 0 1-.983 1.187h-.548a1 1 0 0 1-.916-.599l-1.314-2.48a66 66 0 0 1 1.692.064q.491.026.966.06"/></svg>`,
|
||||
fire: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-flame"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 2c0 -.88 1.056 -1.331 1.692 -.722c1.958 1.876 3.096 5.995 1.75 9.12l-.08 .174l.012 .003c.625 .133 1.203 -.43 2.303 -2.173l.14 -.224a1 1 0 0 1 1.582 -.153c1.334 1.435 2.601 4.377 2.601 6.27c0 4.265 -3.591 7.705 -8 7.705s-8 -3.44 -8 -7.706c0 -2.252 1.022 -4.716 2.632 -6.301l.605 -.589c.241 -.236 .434 -.43 .618 -.624c1.43 -1.512 2.145 -2.924 2.145 -4.78" /></svg>`,
|
||||
megaphone: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-megaphone-fill" viewBox="0 0 16 16"><path d="M13 2.5a1.5 1.5 0 0 1 3 0v11a1.5 1.5 0 0 1-3 0zm-1 .724c-2.067.95-4.539 1.481-7 1.656v6.237a25 25 0 0 1 1.088.085c2.053.204 4.038.668 5.912 1.56zm-8 7.841V4.934c-.68.027-1.399.043-2.008.053A2.02 2.02 0 0 0 0 7v2c0 1.106.896 1.996 1.994 2.009l.496.008a64 64 0 0 1 1.51.048m1.39 1.081q.428.032.85.078l.253 1.69a1 1 0 0 1-.983 1.187h-.548a1 1 0 0 1-.916-.599l-1.314-2.48a66 66 0 0 1 1.692.064q.491.026.966.06"/></svg>`,
|
||||
dragon: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M18.43 255.8L192 224L100.8 292.6C90.67 302.8 97.8 320 112 320h222.7c-9.499-26.5-14.75-54.5-14.75-83.38V194.2L200.3 106.8C176.5 90.88 145 92.75 123.3 111.2l-117.5 116.4C-6.562 238 2.436 258 18.43 255.8zM575.2 289.9l-100.7-50.25c-16.25-8.125-26.5-24.75-26.5-43V160h63.99l28.12 22.62C546.1 188.6 554.2 192 562.7 192h30.1c11.1 0 23.12-6.875 28.5-17.75l14.37-28.62c5.374-10.87 4.25-23.75-2.999-33.5l-74.49-99.37C552.1 4.75 543.5 0 533.5 0H296C288.9 0 285.4 8.625 290.4 13.62L351.1 64L292.4 88.75c-5.874 3-5.874 11.37 0 14.37L351.1 128l-.0011 108.6c0 72 35.99 139.4 95.99 179.4c-195.6 6.75-344.4 41-434.1 60.88c-8.124 1.75-13.87 9-13.87 17.38C.0463 504 8.045 512 17.79 512h499.1c63.24 0 119.6-47.5 122.1-110.8C642.3 354 617.1 310.9 575.2 289.9zM489.1 66.25l45.74 11.38c-2.75 11-12.5 18.88-24.12 18.25C497.7 95.25 484.8 83.38 489.1 66.25z"/></svg>`,
|
||||
rising_star: `<svg width="1em" height="1em" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17.5509 6.91102L15.5716 8.59852L16.1643 11.1108C16.2061 11.2869 16.195 11.4714 16.1325 11.6412C16.0699 11.811 15.9587 11.9587 15.8127 12.0656C15.6651 12.174 15.4888 12.2365 15.3058 12.2453C15.1229 12.254 14.9414 12.2087 14.7841 12.1148L12.5341 10.7789L10.2841 12.1148C10.1268 12.2087 9.94528 12.254 9.76231 12.2453C9.57935 12.2365 9.40303 12.174 9.2554 12.0656C9.10948 11.9586 8.99833 11.811 8.9358 11.6412C8.87328 11.4713 8.86216 11.2869 8.90384 11.1108L9.49657 8.59852L7.51657 6.91102C7.37802 6.79275 7.27755 6.63613 7.22781 6.46088C7.17808 6.28563 7.1813 6.09959 7.23708 5.92617C7.29286 5.75275 7.39869 5.59971 7.54126 5.48631C7.68383 5.37291 7.85677 5.30423 8.03829 5.28891L10.656 5.06742L11.677 2.68734C11.749 2.52049 11.8683 2.37837 12.0202 2.27853C12.1721 2.17869 12.3499 2.12549 12.5316 2.12549C12.7134 2.12549 12.8911 2.17869 13.043 2.27853C13.1949 2.37837 13.3142 2.52049 13.3863 2.68734L14.4072 5.06883L17.0242 5.28891C17.2062 5.30319 17.3798 5.37111 17.5231 5.48409C17.6665 5.59707 17.7731 5.75002 17.8294 5.9236C17.8858 6.09718 17.8894 6.28358 17.8399 6.45922C17.7903 6.63486 17.6897 6.79185 17.5509 6.91031V6.91102ZM7.02298 9.03938C6.97074 8.98708 6.9087 8.94559 6.84041 8.91728C6.77213 8.88897 6.69893 8.8744 6.62501 8.8744C6.55109 8.8744 6.47789 8.88897 6.4096 8.91728C6.34132 8.94559 6.27928 8.98708 6.22704 9.03938L2.28954 12.9769C2.18399 13.0824 2.12469 13.2256 2.12469 13.3748C2.12469 13.5241 2.18399 13.6673 2.28954 13.7728C2.39509 13.8784 2.53824 13.9377 2.68751 13.9377C2.83677 13.9377 2.97993 13.8784 3.08548 13.7728L7.02298 9.83531C7.07528 9.78307 7.11677 9.72104 7.14507 9.65275C7.17338 9.58446 7.18795 9.51127 7.18795 9.43735C7.18795 9.36342 7.17338 9.29023 7.14507 9.22194C7.11677 9.15365 7.07528 9.09162 7.02298 9.03938ZM8.14798 12.9769C8.09574 12.9246 8.0337 12.8831 7.96541 12.8548C7.89713 12.8265 7.82393 12.8119 7.75001 12.8119C7.67609 12.8119 7.60289 12.8265 7.5346 12.8548C7.46632 12.8831 7.40428 12.9246 7.35204 12.9769L3.41454 16.9144C3.36228 16.9666 3.32082 17.0287 3.29254 17.097C3.26425 17.1652 3.24969 17.2384 3.24969 17.3123C3.24969 17.3863 3.26425 17.4594 3.29254 17.5277C3.32082 17.596 3.36228 17.6581 3.41454 17.7103C3.52009 17.8159 3.66324 17.8752 3.81251 17.8752C3.88642 17.8752 3.9596 17.8606 4.02789 17.8323C4.09617 17.804 4.15821 17.7626 4.21048 17.7103L8.14798 13.7728C8.20028 13.7206 8.24177 13.6585 8.27007 13.5902C8.29838 13.522 8.31295 13.4488 8.31295 13.3748C8.31295 13.3009 8.29838 13.2277 8.27007 13.1594C8.24177 13.0912 8.20028 13.0291 8.14798 12.9769ZM12.4152 12.9769L8.47774 16.9144C8.37219 17.0199 8.3129 17.1631 8.3129 17.3123C8.3129 17.4616 8.37219 17.6048 8.47774 17.7103C8.58329 17.8159 8.72644 17.8752 8.87571 17.8752C9.02498 17.8752 9.16813 17.8159 9.27368 17.7103L13.2112 13.7728C13.3167 13.6674 13.3761 13.5243 13.3761 13.3751C13.3762 13.2259 13.317 13.0828 13.2115 12.9772C13.1061 12.8717 12.963 12.8123 12.8138 12.8123C12.6646 12.8122 12.5215 12.8714 12.4159 12.9769H12.4152Z" fill="currentColor"/></svg>`,
|
||||
cloud_arrow_up: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 640 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M144 480C64.5 480 0 415.5 0 336c0-62.8 40.2-116.2 96.2-135.9c-.1-2.7-.2-5.4-.2-8.1c0-88.4 71.6-160 160-160c59.3 0 111 32.2 138.7 80.2C409.9 102 428.3 96 448 96c53 0 96 43 96 96c0 12.2-2.3 23.8-6.4 34.6C596 238.4 640 290.1 640 352c0 70.7-57.3 128-128 128H144zm79-217c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l39-39V392c0 13.3 10.7 24 24 24s24-10.7 24-24V257.9l39 39c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-80-80c-9.4-9.4-24.6-9.4-33.9 0l-80 80z" fill="currentColor"/></svg>`,
|
||||
wand: `<svg width="1em" height="1em" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.33437 4.33438L8.15625 4.775C8.0625 4.80937 8 4.9 8 5C8 5.1 8.0625 5.19062 8.15625 5.225L9.33437 5.66563L9.775 6.84375C9.80938 6.9375 9.9 7 10 7C10.1 7 10.1906 6.9375 10.225 6.84375L10.6656 5.66563L11.8438 5.225C11.9375 5.19062 12 5.1 12 5C12 4.9 11.9375 4.80937 11.8438 4.775L10.6656 4.33438L10.225 3.15625C10.1906 3.0625 10.1 3 10 3C9.9 3 9.80938 3.0625 9.775 3.15625L9.33437 4.33438ZM3.44062 15.3562C2.85625 15.9406 2.85625 16.8906 3.44062 17.4781L4.52187 18.5594C5.10625 19.1437 6.05625 19.1437 6.64375 18.5594L18.5594 6.64062C19.1438 6.05625 19.1438 5.10625 18.5594 4.51875L17.4781 3.44063C16.8937 2.85625 15.9437 2.85625 15.3562 3.44063L3.44062 15.3562ZM17.1438 5.58125L13.8625 8.8625L13.1344 8.13438L16.4156 4.85312L17.1438 5.58125ZM2.23438 6.6625C2.09375 6.71562 2 6.85 2 7C2 7.15 2.09375 7.28438 2.23438 7.3375L4 8L4.6625 9.76562C4.71562 9.90625 4.85 10 5 10C5.15 10 5.28438 9.90625 5.3375 9.76562L6 8L7.76562 7.3375C7.90625 7.28438 8 7.15 8 7C8 6.85 7.90625 6.71562 7.76562 6.6625L6 6L5.3375 4.23438C5.28438 4.09375 5.15 4 5 4C4.85 4 4.71562 4.09375 4.6625 4.23438L4 6L2.23438 6.6625ZM13.2344 14.6625C13.0938 14.7156 13 14.85 13 15C13 15.15 13.0938 15.2844 13.2344 15.3375L15 16L15.6625 17.7656C15.7156 17.9062 15.85 18 16 18C16.15 18 16.2844 17.9062 16.3375 17.7656L17 16L18.7656 15.3375C18.9062 15.2844 19 15.15 19 15C19 14.85 18.9062 14.7156 18.7656 14.6625L17 14L16.3375 12.2344C16.2844 12.0938 16.15 12 16 12C15.85 12 15.7156 12.0938 15.6625 12.2344L15 14L13.2344 14.6625Z" fill="currentColor"/></svg>`,
|
||||
clapperBoard: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M326.1 160l127.4-127.4C451.7 32.39 449.9 32 448 32h-86.06l-128 128H326.1zM166.1 160l128-128H201.9l-128 128H166.1zM497.7 56.19L393.9 160H512V96C512 80.87 506.5 67.15 497.7 56.19zM134.1 32H64C28.65 32 0 60.65 0 96v64h6.062L134.1 32zM0 416c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V192H0V416z"/></svg>`,
|
||||
wand: `<svg width="1.2em" height="1.2em" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.33437 4.33438L8.15625 4.775C8.0625 4.80937 8 4.9 8 5C8 5.1 8.0625 5.19062 8.15625 5.225L9.33437 5.66563L9.775 6.84375C9.80938 6.9375 9.9 7 10 7C10.1 7 10.1906 6.9375 10.225 6.84375L10.6656 5.66563L11.8438 5.225C11.9375 5.19062 12 5.1 12 5C12 4.9 11.9375 4.80937 11.8438 4.775L10.6656 4.33438L10.225 3.15625C10.1906 3.0625 10.1 3 10 3C9.9 3 9.80938 3.0625 9.775 3.15625L9.33437 4.33438ZM3.44062 15.3562C2.85625 15.9406 2.85625 16.8906 3.44062 17.4781L4.52187 18.5594C5.10625 19.1437 6.05625 19.1437 6.64375 18.5594L18.5594 6.64062C19.1438 6.05625 19.1438 5.10625 18.5594 4.51875L17.4781 3.44063C16.8937 2.85625 15.9437 2.85625 15.3562 3.44063L3.44062 15.3562ZM17.1438 5.58125L13.8625 8.8625L13.1344 8.13438L16.4156 4.85312L17.1438 5.58125ZM2.23438 6.6625C2.09375 6.71562 2 6.85 2 7C2 7.15 2.09375 7.28438 2.23438 7.3375L4 8L4.6625 9.76562C4.71562 9.90625 4.85 10 5 10C5.15 10 5.28438 9.90625 5.3375 9.76562L6 8L7.76562 7.3375C7.90625 7.28438 8 7.15 8 7C8 6.85 7.90625 6.71562 7.76562 6.6625L6 6L5.3375 4.23438C5.28438 4.09375 5.15 4 5 4C4.85 4 4.71562 4.09375 4.6625 4.23438L4 6L2.23438 6.6625ZM13.2344 14.6625C13.0938 14.7156 13 14.85 13 15C13 15.15 13.0938 15.2844 13.2344 15.3375L15 16L15.6625 17.7656C15.7156 17.9062 15.85 18 16 18C16.15 18 16.2844 17.9062 16.3375 17.7656L17 16L18.7656 15.3375C18.9062 15.2844 19 15.15 19 15C19 14.85 18.9062 14.7156 18.7656 14.6625L17 14L16.3375 12.2344C16.2844 12.0938 16.15 12 16 12C15.85 12 15.7156 12.0938 15.6625 12.2344L15 14L13.2344 14.6625Z" fill="currentColor"/></svg>`,
|
||||
clapper_board: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M326.1 160l127.4-127.4C451.7 32.39 449.9 32 448 32h-86.06l-128 128H326.1zM166.1 160l128-128H201.9l-128 128H166.1zM497.7 56.19L393.9 160H512V96C512 80.87 506.5 67.15 497.7 56.19zM134.1 32H64C28.65 32 0 60.65 0 96v64h6.062L134.1 32zM0 416c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V192H0V416z"/></svg>`,
|
||||
bookmark: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M384 48V512l-192-112L0 512V48C0 21.5 21.5 0 48 0h288C362.5 0 384 21.5 384 48z"/></svg>`,
|
||||
};
|
||||
|
||||
export const UserIcon = memo((props: UserIconProps) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import classNames from "classnames";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
|
@ -25,7 +26,12 @@ export function EditButton(props: EditButtonProps) {
|
|||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex h-12 items-center overflow-hidden rounded-full bg-background-secondary px-4 py-2 text-white transition-[background-color,transform] hover:bg-background-secondaryHover active:scale-105"
|
||||
className={classNames(
|
||||
"h-12 items-center overflow-hidden rounded-full bg-background-secondary px-4 py-2 text-white transition-[background-color,transform] hover:bg-background-secondaryHover active:scale-105",
|
||||
{
|
||||
"hidden sm:flex": props.editing,
|
||||
},
|
||||
)}
|
||||
id={props.id} // Assign id to the button
|
||||
>
|
||||
<span ref={parent}>
|
||||
|
|
|
|||
43
src/components/buttons/EditButtonWithText.tsx
Normal file
43
src/components/buttons/EditButtonWithText.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface EditButtonWithTextProps {
|
||||
editing: boolean;
|
||||
onEdit?: (editing: boolean) => void;
|
||||
id?: string;
|
||||
text: string;
|
||||
secondaryText?: string;
|
||||
}
|
||||
|
||||
export function EditButtonWithText(props: EditButtonWithTextProps) {
|
||||
const { t } = useTranslation();
|
||||
const [parent] = useAutoAnimate<HTMLSpanElement>();
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
props.onEdit?.(!props.editing);
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex h-12 items-center overflow-hidden rounded-full bg-background-secondary px-4 py-2 text-white transition-[background-color,transform] hover:bg-background-secondaryHover active:scale-105"
|
||||
id={props.id} // Assign id to the button
|
||||
>
|
||||
<span ref={parent}>
|
||||
{props.editing ? (
|
||||
<span className="mx-2 sm:mx-4 whitespace-nowrap">
|
||||
{props.text ?? t("home.mediaList.stopEditing")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mx-2 sm:mx-4 whitespace-nowrap">
|
||||
{props.secondaryText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
230
src/components/form/GroupDropdown.tsx
Normal file
230
src/components/form/GroupDropdown.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { t } from "i18next";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { UserIcon, UserIcons } from "@/components/UserIcon";
|
||||
|
||||
interface GroupDropdownProps {
|
||||
groups: string[];
|
||||
currentGroups: string[];
|
||||
onSelectGroups: (groups: string[]) => void;
|
||||
onCreateGroup: (group: string, icon: UserIcons) => void;
|
||||
onRemoveGroup: (groupToRemove?: string) => void;
|
||||
}
|
||||
|
||||
const userIconList = Object.values(UserIcons);
|
||||
|
||||
function parseGroupString(group: string): { icon: UserIcons; name: string } {
|
||||
const match = group.match(/^\[([a-zA-Z0-9_]+)\](.*)$/);
|
||||
if (match) {
|
||||
const iconKey = match[1].toUpperCase() as keyof typeof UserIcons;
|
||||
const icon = UserIcons[iconKey] || userIconList[0];
|
||||
const name = match[2].trim();
|
||||
return { icon, name };
|
||||
}
|
||||
return { icon: userIconList[0], name: group };
|
||||
}
|
||||
|
||||
export function GroupDropdown({
|
||||
groups,
|
||||
currentGroups,
|
||||
onSelectGroups,
|
||||
onCreateGroup,
|
||||
onRemoveGroup,
|
||||
}: GroupDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newGroup, setNewGroup] = useState("");
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [selectedIcon, setSelectedIcon] = useState<UserIcons>(userIconList[0]);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
setShowInput(false);
|
||||
setNewGroup("");
|
||||
setSelectedIcon(userIconList[0]);
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleToggleGroup = (group: string) => {
|
||||
let newGroups;
|
||||
if (currentGroups.includes(group)) {
|
||||
newGroups = currentGroups.filter((g) => g !== group);
|
||||
} else {
|
||||
newGroups = [...currentGroups, group];
|
||||
}
|
||||
onSelectGroups(newGroups);
|
||||
};
|
||||
|
||||
const handleCreate = (group: string, icon: UserIcons) => {
|
||||
const groupString = `[${icon}]${group}`;
|
||||
onCreateGroup(groupString, icon);
|
||||
setOpen(false);
|
||||
setShowInput(false);
|
||||
setNewGroup("");
|
||||
setSelectedIcon(userIconList[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative min-w-[200px]">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-xs bg-background-main border border-background-secondary rounded-lg text-white flex justify-between items-center hover:bg-mediaCard-hoverBackground transition-colors"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{currentGroups.length > 0 ? (
|
||||
<span className="flex flex-wrap gap-1 items-center">
|
||||
{currentGroups.map((group) => {
|
||||
const { icon, name } = parseGroupString(group);
|
||||
return (
|
||||
<span
|
||||
key={group}
|
||||
className="flex items-center gap-1 bg-type-link/20 px-2 py-1 rounded text-type-link text-xs"
|
||||
>
|
||||
<UserIcon icon={icon} className="inline-block w-4 h-4" />
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-type-secondary">
|
||||
{t("home.bookmarks.groups.dropdown.placeholderButton")}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 text-type-secondary">
|
||||
<Icon
|
||||
icon={open ? Icons.CHEVRON_UP : Icons.CHEVRON_DOWN}
|
||||
className="text-base"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute min-w-full z-[150] mt-1 end-0 bg-background-main border border-background-secondary rounded-lg shadow-lg py-1 pb-3 text-sm">
|
||||
{groups.length === 0 && !showInput && (
|
||||
<div className="px-4 py-2 text-type-secondary">
|
||||
{t("home.bookmarks.groups.dropdown.empty")}
|
||||
</div>
|
||||
)}
|
||||
{groups.map((group) => {
|
||||
const { icon, name } = parseGroupString(group);
|
||||
return (
|
||||
<label
|
||||
key={group}
|
||||
className="flex items-center gap-2 mx-1 px-3 py-2 hover:bg-mediaCard-hoverBackground rounded-md cursor-pointer transition-colors text-type-link/80"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentGroups.includes(group)}
|
||||
onChange={() => handleToggleGroup(group)}
|
||||
className="accent-type-link"
|
||||
/>
|
||||
<span className="w-5 h-5 flex items-center justify-center ml-1">
|
||||
<UserIcon
|
||||
icon={icon}
|
||||
className="inline-block w-full h-full"
|
||||
/>
|
||||
</span>
|
||||
{name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-col gap-2 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newGroup}
|
||||
onChange={(e) => setNewGroup(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded bg-background-main text-white border border-background-secondary text-xs min-w-0 placeholder:text-type-secondary"
|
||||
placeholder="Group name"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreate(newGroup, selectedIcon);
|
||||
if (e.key === "Escape") setShowInput(false);
|
||||
}}
|
||||
style={{ minWidth: 0 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-type-link font-bold px-2 py-1 min-w-[2.5rem]"
|
||||
onClick={() => handleCreate(newGroup, selectedIcon)}
|
||||
disabled={!newGroup.trim()}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("home.bookmarks.groups.dropdown.addButton")}
|
||||
</button>
|
||||
</div>
|
||||
{newGroup.trim().length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap pt-2 w-full justify-center">
|
||||
{userIconList.map((icon) => (
|
||||
<button
|
||||
type="button"
|
||||
key={icon}
|
||||
className={`rounded p-1 border-2 ${
|
||||
selectedIcon === icon
|
||||
? "border-type-link bg-mediaCard-hoverBackground"
|
||||
: "border-transparent hover:border-background-secondary"
|
||||
}`}
|
||||
onClick={() => setSelectedIcon(icon)}
|
||||
>
|
||||
<span className="w-5 h-5 flex items-center justify-center">
|
||||
<UserIcon
|
||||
icon={icon}
|
||||
className={`w-full h-full ${selectedIcon === icon ? "text-type-link" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{currentGroups.length > 0 && (
|
||||
<div className="border-t border-background-secondary pt-2 px-4">
|
||||
<div className="text-xs text-red-400 mb-1">
|
||||
{t("home.bookmarks.groups.dropdown.removeFromGroup")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentGroups.map((group) => {
|
||||
const { icon, name } = parseGroupString(group);
|
||||
return (
|
||||
<button
|
||||
key={group}
|
||||
type="button"
|
||||
className="flex items-center gap-1 px-2 py-1 rounded bg-red-900/30 text-red-300 text-xs hover:bg-red-700/30 transition-colors"
|
||||
onClick={() => onRemoveGroup(group)}
|
||||
>
|
||||
<UserIcon icon={icon} className="inline-block w-4 h-4" />
|
||||
{name}
|
||||
<span className="ml-1">×</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-xs text-red-400 underline hover:text-red-300 transition-colors"
|
||||
onClick={() => onRemoveGroup()}
|
||||
>
|
||||
{t("home.bookmarks.groups.dropdown.removeAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { NoUserAvatar, UserAvatar } from "@/components/Avatar";
|
|||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { LinksDropdown } from "@/components/LinksDropdown";
|
||||
import { useNotifications } from "@/components/overlays/notificationsModal";
|
||||
import { Lightbar } from "@/components/utils/Lightbar";
|
||||
import { useAuth } from "@/hooks/auth/useAuth";
|
||||
import { BlurEllipsis } from "@/pages/layouts/SubPageLayout";
|
||||
|
|
@ -27,6 +28,7 @@ export function Navigation(props: NavigationProps) {
|
|||
const navigate = useNavigate();
|
||||
const { loggedIn } = useAuth();
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
const { openNotifications, getUnreadCount } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
|
|
@ -182,6 +184,23 @@ export function Navigation(props: NavigationProps) {
|
|||
/>
|
||||
</a>
|
||||
))}
|
||||
<a
|
||||
onClick={() => openNotifications()}
|
||||
rel="noreferrer"
|
||||
className="text-xl text-white tabbable rounded-full backdrop-blur-lg relative"
|
||||
>
|
||||
<IconPatch icon={Icons.BELL} clickable downsized navigation />
|
||||
{(() => {
|
||||
const count = getUnreadCount();
|
||||
const shouldShow =
|
||||
typeof count === "number" ? count > 0 : count === "99+";
|
||||
return shouldShow ? (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[16px] h-4 px-1 flex items-center justify-center">
|
||||
{count}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative pointer-events-auto">
|
||||
<LinksDropdown>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ interface SectionHeadingProps {
|
|||
title: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
customIcon?: ReactNode;
|
||||
}
|
||||
|
||||
export function SectionHeading(props: SectionHeadingProps) {
|
||||
|
|
@ -14,7 +15,11 @@ export function SectionHeading(props: SectionHeadingProps) {
|
|||
<div className={props.className}>
|
||||
<div className="mb-5 flex items-center">
|
||||
<p className="flex flex-1 items-center font-bold uppercase text-type-text z-[19]">
|
||||
{props.icon ? (
|
||||
{props.customIcon ? (
|
||||
<span className="mr-2 text-xl flex items-center justify-center">
|
||||
{props.customIcon}
|
||||
</span>
|
||||
) : props.icon ? (
|
||||
<span className="mr-2 text-xl">
|
||||
<Icon icon={props.icon} />
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,14 @@ import { IconPatch } from "../buttons/IconPatch";
|
|||
|
||||
interface MediaBookmarkProps {
|
||||
media: MediaItem;
|
||||
group?: string[];
|
||||
}
|
||||
|
||||
export function MediaBookmarkButton({ media }: MediaBookmarkProps) {
|
||||
export function MediaBookmarkButton({ media, group }: MediaBookmarkProps) {
|
||||
const addBookmark = useBookmarkStore((s) => s.addBookmark);
|
||||
const addBookmarkWithGroups = useBookmarkStore(
|
||||
(s) => s.addBookmarkWithGroups,
|
||||
);
|
||||
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
|
||||
const bookmarks = useBookmarkStore((s) => s.bookmarks);
|
||||
const meta: PlayerMeta | undefined = useMemo(() => {
|
||||
|
|
@ -31,8 +35,16 @@ export function MediaBookmarkButton({ media }: MediaBookmarkProps) {
|
|||
const toggleBookmark = useCallback(() => {
|
||||
if (!meta) return;
|
||||
if (isBookmarked) removeBookmark(meta.tmdbId);
|
||||
else if (group && group.length > 0) addBookmarkWithGroups(meta, group);
|
||||
else addBookmark(meta);
|
||||
}, [isBookmarked, meta, addBookmark, removeBookmark]);
|
||||
}, [
|
||||
isBookmarked,
|
||||
meta,
|
||||
addBookmark,
|
||||
addBookmarkWithGroups,
|
||||
removeBookmark,
|
||||
group,
|
||||
]);
|
||||
|
||||
const buttonOpacityClass =
|
||||
media.year === undefined ? "hover:opacity-100" : "hover:opacity-95";
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ import { mediaItemToId } from "@/backend/metadata/tmdb";
|
|||
import { DotList } from "@/components/text/DotList";
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
import { MediaBookmarkButton } from "./MediaBookmark";
|
||||
import { IconPatch } from "../buttons/IconPatch";
|
||||
import { Icon, Icons } from "../Icon";
|
||||
import { DetailsModal } from "../overlays/details/DetailsModal";
|
||||
import { useModal } from "../overlays/Modal";
|
||||
import { DetailsModal } from "../overlays/detailsModal";
|
||||
|
||||
export interface MediaCardProps {
|
||||
media: MediaItem;
|
||||
|
|
@ -223,7 +223,7 @@ export function MediaCard(props: MediaCardProps) {
|
|||
id: number;
|
||||
type: "movie" | "show";
|
||||
} | null>(null);
|
||||
const detailsModal = useModal("details");
|
||||
const { showModal } = useOverlayStack();
|
||||
const enableDetailsModal = usePreferencesStore(
|
||||
(state) => state.enableDetailsModal,
|
||||
);
|
||||
|
|
@ -258,8 +258,8 @@ export function MediaCard(props: MediaCardProps) {
|
|||
id: Number(media.id),
|
||||
type: media.type === "movie" ? "movie" : "show",
|
||||
});
|
||||
detailsModal.show();
|
||||
}, [media, detailsModal, onShowDetails]);
|
||||
showModal("details");
|
||||
}, [media, showModal, onShowDetails]);
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if (enableDetailsModal && canLink) {
|
||||
|
|
|
|||
52
src/components/overlays/EditGroupOrderModal.tsx
Normal file
52
src/components/overlays/EditGroupOrderModal.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Item, SortableList } from "@/components/form/SortableList";
|
||||
import { Modal, ModalCard } from "@/components/overlays/Modal";
|
||||
import { Heading2, Paragraph } from "@/components/utils/Text";
|
||||
|
||||
interface EditGroupOrderModalProps {
|
||||
id: string;
|
||||
isShown: boolean;
|
||||
items: Item[];
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
onItemsChange: (newItems: Item[]) => void;
|
||||
}
|
||||
|
||||
export function EditGroupOrderModal({
|
||||
id,
|
||||
isShown,
|
||||
items,
|
||||
onCancel,
|
||||
onSave,
|
||||
onItemsChange,
|
||||
}: EditGroupOrderModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isShown) return null;
|
||||
|
||||
return (
|
||||
<Modal id={id}>
|
||||
<ModalCard>
|
||||
<Heading2 className="!my-0">
|
||||
{t("home.bookmarks.groups.reorder.title")}
|
||||
</Heading2>
|
||||
<Paragraph className="mt-4">
|
||||
{t("home.bookmarks.groups.reorder.description")}
|
||||
</Paragraph>
|
||||
<div>
|
||||
<SortableList items={items} setItems={onItemsChange} />
|
||||
</div>
|
||||
<div className="flex gap-4 mt-6 justify-end">
|
||||
<Button theme="secondary" onClick={onCancel}>
|
||||
{t("home.bookmarks.groups.reorder.cancel")}
|
||||
</Button>
|
||||
<Button theme="purple" onClick={onSave}>
|
||||
{t("home.bookmarks.groups.reorder.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalCard>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,15 +7,15 @@ import { Icons } from "@/components/Icon";
|
|||
import { OverlayPortal } from "@/components/overlays/OverlayDisplay";
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
import { Heading2 } from "@/components/utils/Text";
|
||||
import { useQueryParam } from "@/hooks/useQueryParams";
|
||||
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||
|
||||
export function useModal(id: string) {
|
||||
const [currentModal, setCurrentModal] = useQueryParam("m");
|
||||
const show = useCallback(() => setCurrentModal(id), [id, setCurrentModal]);
|
||||
const hide = useCallback(() => setCurrentModal(null), [setCurrentModal]);
|
||||
const { showModal, hideModal, isModalVisible } = useOverlayStack();
|
||||
const show = useCallback(() => showModal(id), [id, showModal]);
|
||||
const hide = useCallback(() => hideModal(id), [id, hideModal]);
|
||||
return {
|
||||
id,
|
||||
isShown: currentModal === id,
|
||||
isShown: isModalVisible(id),
|
||||
show,
|
||||
hide,
|
||||
};
|
||||
|
|
@ -33,9 +33,17 @@ export function ModalCard(props: { children?: ReactNode }) {
|
|||
|
||||
export function Modal(props: { id: string; children?: ReactNode }) {
|
||||
const modal = useModal(props.id);
|
||||
const { modalStack } = useOverlayStack();
|
||||
const modalIndex = modalStack.indexOf(props.id);
|
||||
const zIndex = modalIndex >= 0 ? 1000 + modalIndex : 999;
|
||||
|
||||
return (
|
||||
<OverlayPortal darken close={modal.hide} show={modal.isShown}>
|
||||
<OverlayPortal
|
||||
darken
|
||||
close={modal.hide}
|
||||
show={modal.isShown}
|
||||
zIndex={zIndex}
|
||||
>
|
||||
<Helmet>
|
||||
<html data-no-scroll />
|
||||
</Helmet>
|
||||
|
|
|
|||
|
|
@ -77,14 +77,55 @@ export function OverlayPortal(props: {
|
|||
show?: boolean;
|
||||
close?: () => void;
|
||||
durationClass?: string;
|
||||
zIndex?: number;
|
||||
}) {
|
||||
const [portalElement, setPortalElement] = useState<Element | null>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const close = props.close;
|
||||
const zIndex = props.zIndex ?? 999;
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current?.closest(".popout-location");
|
||||
setPortalElement(element ?? document.body);
|
||||
|
||||
// Ensure DOM is ready before enabling focus trap
|
||||
const timer = setTimeout(() => {
|
||||
setIsReady(true);
|
||||
}, 100); // Increased delay to ensure DOM is fully rendered
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Add global error handler for unhandled promise rejections
|
||||
useEffect(() => {
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
if (
|
||||
event.reason &&
|
||||
typeof event.reason === "object" &&
|
||||
"message" in event.reason
|
||||
) {
|
||||
const message = event.reason.message;
|
||||
if (
|
||||
message &&
|
||||
typeof message === "string" &&
|
||||
message.includes("matches.call")
|
||||
) {
|
||||
console.warn(
|
||||
"Caught focus-trap matches.call error, preventing crash:",
|
||||
event.reason,
|
||||
);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("unhandledrejection", handleUnhandledRejection);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"unhandledrejection",
|
||||
handleUnhandledRejection,
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
@ -92,8 +133,23 @@ export function OverlayPortal(props: {
|
|||
{portalElement
|
||||
? createPortal(
|
||||
<Transition show={props.show} animation="none">
|
||||
<FocusTrap>
|
||||
<div className="popout-wrapper fixed overflow-hidden pointer-events-auto inset-0 z-[999] select-none">
|
||||
<FocusTrap
|
||||
active={isReady && !!props.show}
|
||||
focusTrapOptions={{
|
||||
allowOutsideClick: true,
|
||||
clickOutsideDeactivates: true,
|
||||
fallbackFocus: () => document.body,
|
||||
returnFocusOnDeactivate: true,
|
||||
escapeDeactivates: true,
|
||||
preventScroll: true,
|
||||
// Disable the problematic check that causes the matches.call error
|
||||
checkCanFocusTrap: () => Promise.resolve(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="popout-wrapper fixed overflow-hidden pointer-events-auto inset-0 select-none"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<Transition animation="fade" isChild>
|
||||
<div
|
||||
onClick={close}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import { Link } from "react-router-dom";
|
|||
import { Button } from "@/components/buttons/Button";
|
||||
import { Dropdown } from "@/components/form/Dropdown";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Modal, ModalCard, useModal } from "@/components/overlays/Modal";
|
||||
import { hasAired } from "@/components/player/utils/aired";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
|
||||
import { EpisodeCarouselProps } from "./types";
|
||||
import { EpisodeCarouselProps } from "../../types";
|
||||
|
||||
export function EpisodeCarousel({
|
||||
episodes,
|
||||
|
|
@ -25,6 +26,7 @@ export function EpisodeCarousel({
|
|||
const [showEpisodeMenu, setShowEpisodeMenu] = useState(false);
|
||||
const [customSeason, setCustomSeason] = useState("");
|
||||
const [customEpisode, setCustomEpisode] = useState("");
|
||||
const [SeasonWatched, setSeasonWatched] = useState(false);
|
||||
const [expandedEpisodes, setExpandedEpisodes] = useState<{
|
||||
[key: number]: boolean;
|
||||
}>({});
|
||||
|
|
@ -38,6 +40,7 @@ export function EpisodeCarousel({
|
|||
[key: number]: HTMLParagraphElement | null;
|
||||
}>({});
|
||||
const updateItem = useProgressStore((s) => s.updateItem);
|
||||
const confirmModal = useModal("season-watch-confirm");
|
||||
|
||||
const handleScroll = (direction: "left" | "right") => {
|
||||
if (!carouselRef.current) return;
|
||||
|
|
@ -203,10 +206,66 @@ export function EpisodeCarousel({
|
|||
}
|
||||
};
|
||||
|
||||
// Toggle whole season watch status
|
||||
const toggleSeasonWatchStatus = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
confirmModal.show();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
confirmModal.hide();
|
||||
};
|
||||
|
||||
const currentSeasonEpisodes = episodes.filter(
|
||||
(ep) => ep.season_number === selectedSeason,
|
||||
);
|
||||
|
||||
const handleConfirm = (event: React.MouseEvent) => {
|
||||
try {
|
||||
const episodeWatchedStatus: boolean[] = [];
|
||||
currentSeasonEpisodes.forEach((episode: any) => {
|
||||
const episodeProgress =
|
||||
progress[mediaId?.toString() ?? ""]?.episodes?.[episode.id];
|
||||
const percentage = episodeProgress
|
||||
? (episodeProgress.progress.watched /
|
||||
episodeProgress.progress.duration) *
|
||||
100
|
||||
: 0;
|
||||
const isAired = hasAired(episode.air_date);
|
||||
const isWatched = percentage > 90;
|
||||
if (isAired && !isWatched) {
|
||||
episodeWatchedStatus.push(isWatched);
|
||||
}
|
||||
});
|
||||
|
||||
const hasUnwatched = episodeWatchedStatus.length >= 1;
|
||||
|
||||
currentSeasonEpisodes.forEach((episode: any) => {
|
||||
const episodeProgress =
|
||||
progress[mediaId?.toString() ?? ""]?.episodes?.[episode.id];
|
||||
const percentage = episodeProgress
|
||||
? (episodeProgress.progress.watched /
|
||||
episodeProgress.progress.duration) *
|
||||
100
|
||||
: 0;
|
||||
const isAired = hasAired(episode.air_date);
|
||||
const isWatched = percentage > 90;
|
||||
if (hasUnwatched && isAired && !isWatched) {
|
||||
toggleWatchStatus(episode.id, event); // Mark unwatched as watched
|
||||
} else if (!hasUnwatched && isAired && isWatched) {
|
||||
toggleWatchStatus(episode.id, event); // Mark watched as unwatched
|
||||
}
|
||||
});
|
||||
|
||||
confirmModal.hide();
|
||||
} catch (error) {
|
||||
console.error("Error in handleConfirm:", error);
|
||||
confirmModal.hide();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEpisodeExpansion = (
|
||||
episodeId: number,
|
||||
event: React.MouseEvent,
|
||||
|
|
@ -259,6 +318,32 @@ export function EpisodeCarousel({
|
|||
};
|
||||
}, [episodes, expandedEpisodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const episodeWatchedStatus: boolean[] = [];
|
||||
|
||||
currentSeasonEpisodes.forEach((episode: any) => {
|
||||
const episodeProgress =
|
||||
progress[mediaId?.toString() ?? ""]?.episodes?.[episode.id];
|
||||
const percentage = episodeProgress
|
||||
? (episodeProgress.progress.watched /
|
||||
episodeProgress.progress.duration) *
|
||||
100
|
||||
: 0;
|
||||
const isAired = hasAired(episode.air_date);
|
||||
const isWatched = percentage > 90;
|
||||
|
||||
if (isAired && !isWatched) {
|
||||
episodeWatchedStatus.push(isWatched);
|
||||
}
|
||||
});
|
||||
|
||||
if (episodeWatchedStatus.length >= 1) {
|
||||
setSeasonWatched(true); // If no episodes are watched, we want to mark all as watched
|
||||
} else {
|
||||
setSeasonWatched(false); // if all episodes are watched, we want to mark all as unwatched
|
||||
}
|
||||
}, [currentSeasonEpisodes, episodes, mediaId, progress]);
|
||||
|
||||
return (
|
||||
<div className="mt-6 md:mt-0">
|
||||
{/* Season Selector */}
|
||||
|
|
@ -323,17 +408,50 @@ export function EpisodeCarousel({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Dropdown
|
||||
options={seasons.map((season) => ({
|
||||
id: season.season_number.toString(),
|
||||
name: `${t("details.season")} ${season.season_number}`,
|
||||
}))}
|
||||
selectedItem={{
|
||||
id: selectedSeason.toString(),
|
||||
name: `${t("details.season")} ${selectedSeason}`,
|
||||
}}
|
||||
setSelectedItem={(item) => onSeasonChange(Number(item.id))}
|
||||
/>
|
||||
|
||||
{/* Season Watched Confirmation */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Modal id={confirmModal.id}>
|
||||
<ModalCard>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
{SeasonWatched
|
||||
? t("media.seasonWatched")
|
||||
: t("media.seasonUnwatched")}
|
||||
</h3>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button theme="secondary" onClick={handleCancel}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button theme="purple" onClick={handleConfirm}>
|
||||
{t("actions.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalCard>
|
||||
</Modal>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleSeasonWatchStatus(e)}
|
||||
className="p-1.5 bg-dropdown-background hover:bg-dropdown-hoverBackground transition-colors rounded-full"
|
||||
title={t("Mark season as watched")}
|
||||
>
|
||||
<Icon
|
||||
icon={SeasonWatched ? Icons.EYE : Icons.EYE_SLASH}
|
||||
className="h-5 w-5 text-white"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Dropdown
|
||||
options={seasons.map((season) => ({
|
||||
id: season.season_number.toString(),
|
||||
name: `${t("details.season")} ${season.season_number}`,
|
||||
}))}
|
||||
selectedItem={{
|
||||
id: selectedSeason.toString(),
|
||||
name: `${t("details.season")} ${selectedSeason}`,
|
||||
}}
|
||||
setSelectedItem={(item) => onSeasonChange(Number(item.id))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Episodes Carousel */}
|
||||
|
|
@ -359,7 +477,6 @@ export function EpisodeCarousel({
|
|||
>
|
||||
{/* Add padding before the first card */}
|
||||
<div className="flex-shrink-0 w-4" />
|
||||
|
||||
{currentSeasonEpisodes.map((episode) => {
|
||||
const isActive =
|
||||
showProgress?.episode?.id === episode.id.toString();
|
||||
|
|
@ -414,7 +531,7 @@ export function EpisodeCarousel({
|
|||
{episode.episode_number}
|
||||
</span>
|
||||
{!isAired && (
|
||||
<span className="text-video-context-type-main/70 text-sm">
|
||||
<span className="bg-video-context-hoverColor/50 text-video-context-type-main/80 text-sm px-1 py-0.5 rounded-md">
|
||||
{episode.air_date
|
||||
? `(${t("details.airs")} - ${new Date(episode.air_date).toLocaleDateString()})`
|
||||
: `(${t("media.unreleased")})`}
|
||||
|
|
@ -13,12 +13,12 @@ import { scrapeIMDb } from "@/utils/imdbScraper";
|
|||
import { getTmdbLanguageCode } from "@/utils/language";
|
||||
import { scrapeRottenTomatoes } from "@/utils/rottenTomatoesScraper";
|
||||
|
||||
import { DetailsBody } from "./DetailsBody";
|
||||
import { DetailsInfo } from "./DetailsInfo";
|
||||
import { EpisodeCarousel } from "./EpisodeCarousel";
|
||||
import { CastCarousel } from "./PeopleCarousel";
|
||||
import { TrailerOverlay } from "./TrailerOverlay";
|
||||
import { DetailsContentProps } from "./types";
|
||||
import { DetailsContentProps } from "../../types";
|
||||
import { EpisodeCarousel } from "../carousels/EpisodeCarousel";
|
||||
import { CastCarousel } from "../carousels/PeopleCarousel";
|
||||
import { TrailerOverlay } from "../overlays/TrailerOverlay";
|
||||
import { DetailsBody } from "../sections/DetailsBody";
|
||||
import { DetailsInfo } from "../sections/DetailsInfo";
|
||||
|
||||
export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
|
||||
const [imdbData, setImdbData] = useState<any>(null);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
import {
|
||||
|
|
@ -16,18 +16,24 @@ import {
|
|||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||
|
||||
import { useModal } from "../Modal";
|
||||
import { OverlayPortal } from "../OverlayDisplay";
|
||||
import { DetailsContent } from "./DetailsContent";
|
||||
import { DetailsSkeleton } from "./DetailsSkeleton";
|
||||
import { DetailsModalProps } from "./types";
|
||||
import { OverlayPortal } from "../../../OverlayDisplay";
|
||||
import { DetailsModalProps } from "../../types";
|
||||
|
||||
export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
|
||||
const modal = useModal(id);
|
||||
const { hideModal, isModalVisible, modalStack } = useOverlayStack();
|
||||
const [detailsData, setDetailsData] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const modalIndex = modalStack.indexOf(id);
|
||||
const zIndex = modalIndex >= 0 ? 1000 + modalIndex : 999;
|
||||
|
||||
const hide = useCallback(() => hideModal(id), [hideModal, id]);
|
||||
const isShown = isModalVisible(id);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDetails = async () => {
|
||||
if (!data?.id || !data?.type) return;
|
||||
|
|
@ -106,23 +112,24 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
|
|||
}
|
||||
};
|
||||
|
||||
if (modal.isShown && data?.id) {
|
||||
if (isShown && data?.id) {
|
||||
fetchDetails();
|
||||
}
|
||||
}, [modal.isShown, data]);
|
||||
}, [isShown, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (modal.isShown && !data?.id && !isLoading) {
|
||||
modal.hide();
|
||||
if (isShown && !data?.id && !isLoading) {
|
||||
hide();
|
||||
}
|
||||
}, [modal, data, isLoading]);
|
||||
}, [isShown, data, isLoading, hide]);
|
||||
|
||||
return (
|
||||
<OverlayPortal
|
||||
darken
|
||||
close={modal.hide}
|
||||
show={modal.isShown}
|
||||
close={hide}
|
||||
show={isShown}
|
||||
durationClass="duration-500"
|
||||
zIndex={zIndex}
|
||||
>
|
||||
<Helmet>
|
||||
<html data-no-scroll />
|
||||
|
|
@ -132,27 +139,28 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
|
|||
className={classNames(
|
||||
"group -m-[0.705em] rounded-3xl bg-background-main",
|
||||
"max-h-[900px] max-w-[1200px]",
|
||||
"bg-mediaCard-hoverBackground bg-opacity-60 backdrop-filter backdrop-blur-lg shadow-lg overflow-hidden",
|
||||
"bg-mediaCard-hoverBackground/60 backdrop-filter backdrop-blur-lg shadow-lg overflow-hidden",
|
||||
"h-[97%] w-[95%]",
|
||||
"relative",
|
||||
)}
|
||||
>
|
||||
<div className="transition-transform duration-300 h-full">
|
||||
<div className="transition-transform duration-300 h-full relative">
|
||||
<Flare.Light
|
||||
flareSize={300}
|
||||
cssColorVar="--colors-mediaCard-hoverAccent"
|
||||
backgroundClass="bg-mediaCard-hoverBackground duration-100"
|
||||
className="rounded-3xl bg-background-main group-hover:opacity-100"
|
||||
className="rounded-3xl bg-background-main group-hover:opacity-30 transition-opacity duration-300"
|
||||
/>
|
||||
<div className="absolute right-4 top-4 z-50 pointer-events-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="text-s font-semibold text-type-secondary hover:text-white transition-transform hover:scale-95 select-none"
|
||||
onClick={hide}
|
||||
>
|
||||
<IconPatch icon={Icons.X} />
|
||||
</button>
|
||||
</div>
|
||||
<Flare.Child className="pointer-events-auto relative h-full overflow-y-auto scrollbar-none select-text">
|
||||
<div className="absolute right-4 top-4 z-50">
|
||||
<button
|
||||
type="button"
|
||||
className="text-s font-semibold text-type-secondary hover:text-white transition-transform hover:scale-95 select-none"
|
||||
onClick={modal.hide}
|
||||
>
|
||||
<IconPatch icon={Icons.X} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="select-text">
|
||||
{isLoading || !detailsData ? (
|
||||
<DetailsSkeleton />
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
import { TrailerOverlayProps } from "./types";
|
||||
import { TrailerOverlayProps } from "../../types";
|
||||
|
||||
export function TrailerOverlay({ trailerUrl, onClose }: TrailerOverlayProps) {
|
||||
return (
|
||||
|
|
@ -8,10 +8,12 @@ import {
|
|||
} from "@/backend/metadata/traktApi";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { GroupDropdown } from "@/components/form/GroupDropdown";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { MediaBookmarkButton } from "@/components/media/MediaBookmark";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
|
||||
import { DetailsBodyProps } from "./types";
|
||||
import { DetailsBodyProps } from "../../types";
|
||||
|
||||
export function DetailsBody({
|
||||
data,
|
||||
|
|
@ -28,6 +30,58 @@ export function DetailsBody({
|
|||
const [releaseInfo, setReleaseInfo] = useState<TraktReleaseResponse | null>(
|
||||
null,
|
||||
);
|
||||
const addBookmarkWithGroups = useBookmarkStore(
|
||||
(s) => s.addBookmarkWithGroups,
|
||||
);
|
||||
|
||||
const bookmarks = useBookmarkStore((s) => s.bookmarks);
|
||||
const currentGroups = bookmarks[data.id?.toString() ?? ""]?.group || [];
|
||||
|
||||
const allGroups = Array.from(
|
||||
new Set(
|
||||
Object.values(bookmarks)
|
||||
.flatMap((b) => b.group || [])
|
||||
.filter(Boolean),
|
||||
),
|
||||
) as string[];
|
||||
|
||||
const handleSelectGroups = (groups: string[]) => {
|
||||
if (!data.id) return;
|
||||
const meta = {
|
||||
tmdbId: data.id.toString(),
|
||||
title: data.title,
|
||||
type: data.type || "movie",
|
||||
releaseYear: data.releaseDate
|
||||
? new Date(data.releaseDate).getFullYear()
|
||||
: 0,
|
||||
poster: data.posterUrl,
|
||||
};
|
||||
addBookmarkWithGroups(meta, groups);
|
||||
};
|
||||
|
||||
const handleCreateGroup = (group: string) => {
|
||||
handleSelectGroups([...currentGroups, group]);
|
||||
};
|
||||
|
||||
const handleRemoveGroup = (groupToRemove?: string) => {
|
||||
if (!data.id) return;
|
||||
const meta = {
|
||||
tmdbId: data.id.toString(),
|
||||
title: data.title,
|
||||
type: data.type || "movie",
|
||||
releaseYear: data.releaseDate
|
||||
? new Date(data.releaseDate).getFullYear()
|
||||
: 0,
|
||||
poster: data.posterUrl,
|
||||
};
|
||||
if (groupToRemove) {
|
||||
const newGroups = currentGroups.filter((g) => g !== groupToRemove);
|
||||
addBookmarkWithGroups(meta, newGroups);
|
||||
} else {
|
||||
// Remove all groups
|
||||
addBookmarkWithGroups(meta, []);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchReleaseInfo = async () => {
|
||||
|
|
@ -152,66 +206,84 @@ export function DetailsBody({
|
|||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
onClick={onPlayClick}
|
||||
theme="purple"
|
||||
className={classNames(
|
||||
"flex-1 sm:flex-initial sm:w-auto",
|
||||
"gap-2 h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
|
||||
"text-md text-white flex items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Icon icon={Icons.PLAY} className="text-white" />
|
||||
<span className="text-white text-sm pr-1">
|
||||
{data.type === "movie"
|
||||
? !data.releaseDate || new Date(data.releaseDate) > new Date()
|
||||
? t("media.unreleased")
|
||||
: showProgress
|
||||
? t("details.resume")
|
||||
: t("details.play")
|
||||
: showProgress
|
||||
? t("details.resume")
|
||||
: t("details.play")}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{imdbData?.trailer_url && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
onClick={onPlayClick}
|
||||
theme="purple"
|
||||
className={classNames(
|
||||
"flex-1 sm:flex-initial sm:w-auto",
|
||||
"gap-2 h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
|
||||
"text-md text-white flex items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Icon icon={Icons.PLAY} className="text-white" />
|
||||
<span className="text-white text-sm pr-1">
|
||||
{showProgress &&
|
||||
data.type === "show" &&
|
||||
showProgress.season &&
|
||||
showProgress.episode
|
||||
? `${t("details.resume")} S${showProgress.season.number}:E${
|
||||
showProgress.episode.number
|
||||
}`
|
||||
: data.type === "movie"
|
||||
? !data.releaseDate || new Date(data.releaseDate) > new Date()
|
||||
? t("media.unreleased")
|
||||
: showProgress
|
||||
? t("details.resume")
|
||||
: t("details.play")
|
||||
: showProgress
|
||||
? t("details.resume")
|
||||
: t("details.play")}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{imdbData?.trailer_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTrailerClick}
|
||||
className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95"
|
||||
title={t("details.trailer")}
|
||||
>
|
||||
<IconPatch
|
||||
icon={Icons.FILM}
|
||||
className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<MediaBookmarkButton
|
||||
media={{
|
||||
id: data.id?.toString() || "",
|
||||
title: data.title,
|
||||
year: data.releaseDate
|
||||
? new Date(data.releaseDate).getFullYear()
|
||||
: undefined,
|
||||
poster: data.posterUrl,
|
||||
type: data.type || "movie",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTrailerClick}
|
||||
onClick={onShareClick}
|
||||
className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95"
|
||||
title={t("details.trailer")}
|
||||
title="Share"
|
||||
>
|
||||
<IconPatch
|
||||
icon={Icons.FILM}
|
||||
icon={Icons.IOS_SHARE}
|
||||
className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<MediaBookmarkButton
|
||||
media={{
|
||||
id: data.id?.toString() || "",
|
||||
title: data.title,
|
||||
year: data.releaseDate
|
||||
? new Date(data.releaseDate).getFullYear()
|
||||
: undefined,
|
||||
poster: data.backdrop,
|
||||
type: data.type || "movie",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShareClick}
|
||||
className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95"
|
||||
title="Share"
|
||||
>
|
||||
<IconPatch
|
||||
icon={Icons.IOS_SHARE}
|
||||
className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group Dropdown */}
|
||||
<GroupDropdown
|
||||
groups={allGroups}
|
||||
currentGroups={currentGroups}
|
||||
onSelectGroups={handleSelectGroups}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onRemoveGroup={handleRemoveGroup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
|||
import { Trans } from "react-i18next";
|
||||
|
||||
import { DetailsRatings } from "./DetailsRatings";
|
||||
import { DetailsInfoProps } from "./types";
|
||||
import { DetailsInfoProps } from "../../types";
|
||||
|
||||
export function DetailsInfo({
|
||||
data,
|
||||
|
|
@ -66,7 +66,7 @@ export function DetailsInfo({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="md:col-span-1 bg-video-context-border p-4 rounded-lg border-buttons-primary bg-opacity-80">
|
||||
<div className="md:col-span-1 bg-background-secondary/50 group-hover:bg-background-secondary/80 p-4 rounded-lg border-buttons-primary transition-colors duration-300">
|
||||
<div className="space-y-3 text-xs">
|
||||
{data.runtime && (
|
||||
<div className="flex flex-wrap items-center gap-2 text-white/80">
|
||||
|
|
@ -4,7 +4,7 @@ import { PROVIDER_TO_IMAGE_MAP } from "@/backend/metadata/traktApi";
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { getRTIcon } from "@/utils/rottenTomatoesScraper";
|
||||
|
||||
import { DetailsRatingsProps } from "./types";
|
||||
import { DetailsRatingsProps } from "../../types";
|
||||
|
||||
export function DetailsRatings({
|
||||
rtData,
|
||||
28
src/components/overlays/detailsModal/index.ts
Normal file
28
src/components/overlays/detailsModal/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Main exports
|
||||
export { DetailsModal } from "./components/layout/DetailsModal";
|
||||
export type { DetailsModalProps, DetailsContentProps } from "./types";
|
||||
|
||||
// Layout components
|
||||
export { DetailsContent } from "./components/layout/DetailsContent";
|
||||
export { DetailsSkeleton } from "./components/layout/DetailsSkeleton";
|
||||
|
||||
// Section components
|
||||
export { DetailsBody } from "./components/sections/DetailsBody";
|
||||
export { DetailsInfo } from "./components/sections/DetailsInfo";
|
||||
export { DetailsRatings } from "./components/sections/DetailsRatings";
|
||||
|
||||
// Carousel components
|
||||
export { EpisodeCarousel } from "./components/carousels/EpisodeCarousel";
|
||||
export { CastCarousel } from "./components/carousels/PeopleCarousel";
|
||||
|
||||
// Overlay components
|
||||
export { TrailerOverlay } from "./components/overlays/TrailerOverlay";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
DetailsBodyProps,
|
||||
DetailsInfoProps,
|
||||
DetailsRatingsProps,
|
||||
TrailerOverlayProps,
|
||||
EpisodeCarouselProps,
|
||||
} from "./types";
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Link } from "@/pages/migration/utils";
|
||||
|
||||
import { DetailViewProps } from "../types";
|
||||
|
||||
export function DetailView({
|
||||
selectedNotification,
|
||||
goBackToList,
|
||||
getCategoryColor,
|
||||
getCategoryLabel,
|
||||
formatDate,
|
||||
isRead,
|
||||
toggleReadStatus,
|
||||
}: DetailViewProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with back button and toggle read status */}
|
||||
<div className="flex md:flex-row flex-col items-start md:items-center justify-between gap-4 pb-4 border-b border-utils-divider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBackToList}
|
||||
className="text-type-link hover:text-type-linkHover transition-colors flex items-center gap-1 text-sm"
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_LEFT} />
|
||||
<span>Back to notifications</span>
|
||||
</button>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleReadStatus}
|
||||
className={`text-sm transition-colors flex items-center gap-2 px-3 py-1 rounded-md ${
|
||||
isRead
|
||||
? "text-type-link hover:text-type-linkHover bg-background-main/50 hover:bg-background-main/70"
|
||||
: "text-type-secondary hover:text-white bg-background-main/30 hover:bg-background-main/50"
|
||||
}`}
|
||||
>
|
||||
<Icon icon={isRead ? Icons.EYE_SLASH : Icons.EYE} />
|
||||
<span>{isRead ? "Mark as unread" : "Mark as read"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification content */}
|
||||
<div className="space-y-4 overflow-y-auto max-h-[70vh] md:max-h-[60vh]">
|
||||
<div className="flex items-center gap-2">
|
||||
{getCategoryColor(selectedNotification.category) && (
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full ${getCategoryColor(
|
||||
selectedNotification.category,
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
{getCategoryLabel(selectedNotification.category) && (
|
||||
<>
|
||||
<span className="text-sm text-type-secondary">
|
||||
{getCategoryLabel(selectedNotification.category)}
|
||||
</span>
|
||||
{selectedNotification.source && (
|
||||
<>
|
||||
<span className="text-sm text-type-secondary">•</span>
|
||||
<span className="text-sm text-type-secondary text-nowrap">
|
||||
{selectedNotification.source}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-sm text-type-secondary">•</span>
|
||||
<span className="text-sm text-type-secondary">
|
||||
{formatDate(selectedNotification.pubDate)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!getCategoryLabel(selectedNotification.category) && (
|
||||
<>
|
||||
{selectedNotification.source && (
|
||||
<>
|
||||
<span className="text-sm text-type-secondary">
|
||||
{selectedNotification.source}
|
||||
</span>
|
||||
<span className="text-sm text-type-secondary">•</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-sm text-type-secondary">
|
||||
{formatDate(selectedNotification.pubDate)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div
|
||||
className="text-type-secondary leading-relaxed"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedNotification.description
|
||||
.replace(/\n\n/g, "</p><p>")
|
||||
.replace(/\n- /g, "</p><p>• ")
|
||||
.replace(/\n\*\*([^*]+)\*\*/g, "</p><h4>$1</h4><p>")
|
||||
.replace(/^/, "<p>")
|
||||
.replace(/$/, "</p>")
|
||||
.replace(/<p><\/p>/g, "")
|
||||
.replace(
|
||||
/<p>• /g,
|
||||
'<p class="flex items-start gap-2"><span class="text-type-link mt-1">•</span><span>',
|
||||
)
|
||||
.replace(/<\/p>/g, "</span></p>"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedNotification.link && (
|
||||
<div className="pt-4 border-t border-utils-divider">
|
||||
<Link href={selectedNotification.link} target="_blank">
|
||||
<Icon icon={Icons.LINK} />
|
||||
<span>Go to page</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
import { ListViewProps } from "../types";
|
||||
|
||||
export function ListView({
|
||||
notifications,
|
||||
readNotifications,
|
||||
unreadCount,
|
||||
loading,
|
||||
error,
|
||||
containerRef,
|
||||
markAllAsRead,
|
||||
markAllAsUnread,
|
||||
isShiftHeld,
|
||||
onRefresh,
|
||||
onOpenSettings,
|
||||
openNotificationDetail,
|
||||
getCategoryColor,
|
||||
getCategoryLabel,
|
||||
formatDate,
|
||||
}: ListViewProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with refresh and mark all buttons */}
|
||||
<div className="flex gap-4 items-center pb-4 border-b border-utils-divider">
|
||||
<div className="flex flex-col md:flex-row justify-start md:gap-2">
|
||||
<span className="text-sm text-type-secondary">
|
||||
{unreadCount} unread notification{unreadCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{isShiftHeld ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllAsUnread}
|
||||
className="text-sm text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
Mark all as unread
|
||||
</button>
|
||||
) : (
|
||||
unreadCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllAsRead}
|
||||
className="text-sm text-type-link hover:text-type-linkHover transition-colors"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex justify-end gap-2 md:mr-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="text-sm text-type-secondary hover:text-white transition-colors"
|
||||
>
|
||||
<Icon icon={Icons.SETTINGS} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="text-sm text-type-secondary hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Icon
|
||||
icon={Icons.RELOAD}
|
||||
className={loading ? "animate-spin" : ""}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Icon
|
||||
icon={Icons.RELOAD}
|
||||
className="animate-spin rounded-full text-type-secondary text-[2rem]"
|
||||
/>
|
||||
<span className="ml-3 text-type-secondary">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Icon icon={Icons.WARNING} className="text-[2rem] text-red-400" />
|
||||
<p className="text-red-400 mb-2">Failed to load notifications</p>
|
||||
<p className="text-sm text-type-secondary">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notifications list */}
|
||||
{!loading && !error && (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="space-y-4 overflow-y-auto max-h-[70vh] md:max-h-[60vh]"
|
||||
>
|
||||
{notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Icon
|
||||
icon={Icons.BELL}
|
||||
className="text-type-secondary text-[2rem]"
|
||||
/>
|
||||
<p className="text-type-secondary">No notifications available</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => {
|
||||
const isRead = readNotifications.has(notification.guid);
|
||||
return (
|
||||
<div
|
||||
key={notification.guid}
|
||||
className={`p-4 rounded-lg border transition-all cursor-pointer hover:bg-background-main/50 md:mr-2 ${
|
||||
isRead
|
||||
? "bg-background-main border-utils-divider opacity-75"
|
||||
: "bg-background-main border-type-link/70 shadow-sm"
|
||||
}`}
|
||||
onClick={() => openNotificationDetail(notification)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap order-2 sm:order-1">
|
||||
<h3
|
||||
className={`font-medium ${
|
||||
isRead ? "text-type-secondary" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{notification.title}
|
||||
</h3>
|
||||
{!isRead && (
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-1 sm:gap-2 order-1 sm:order-2">
|
||||
{/* Mobile: Source • Category */}
|
||||
<div className="flex items-center gap-1 sm:hidden">
|
||||
{getCategoryColor(notification.category) && (
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${getCategoryColor(
|
||||
notification.category,
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-type-secondary">
|
||||
{getCategoryLabel(notification.category)}
|
||||
</span>
|
||||
{notification.source && (
|
||||
<>
|
||||
<span className="text-xs text-type-secondary">
|
||||
•
|
||||
</span>
|
||||
<span className="text-xs text-type-secondary">
|
||||
{notification.source}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: Source above Category */}
|
||||
<div className="hidden sm:flex sm:flex-col sm:items-end sm:gap-1">
|
||||
{notification.source && (
|
||||
<span className="text-xs text-type-secondary font-medium">
|
||||
{notification.source}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{getCategoryColor(notification.category) && (
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${getCategoryColor(
|
||||
notification.category,
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-type-secondary">
|
||||
{getCategoryLabel(notification.category)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-type-secondary mb-2 line-clamp-2 max-w-[12rem] md:max-w-[30rem] md:pr-8"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
notification.description
|
||||
.replace(/\n\n/g, "</p><p>")
|
||||
.replace(/\n- /g, "</p><p>• ")
|
||||
.replace(
|
||||
/\n\*\*([^*]+)\*\*/g,
|
||||
"</p><h4>$1</h4><p>",
|
||||
)
|
||||
.replace(/^/, "<p>")
|
||||
.replace(/$/, "</p>")
|
||||
.replace(/<p><\/p>/g, "")
|
||||
.replace(
|
||||
/<p>• /g,
|
||||
'<p class="flex items-start gap-2"><span class="text-type-link mt-1">•</span><span>',
|
||||
)
|
||||
.replace(/<\/p>/g, "</span></p>")
|
||||
.substring(0, 150) +
|
||||
(notification.description.length > 150
|
||||
? "..."
|
||||
: ""),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-type-secondary">
|
||||
{formatDate(notification.pubDate)}
|
||||
</span>
|
||||
<Icon
|
||||
icon={Icons.CHEVRON_RIGHT}
|
||||
className="text-type-link"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,424 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
import { DetailView } from "./DetailView";
|
||||
import { ListView } from "./ListView";
|
||||
import { SettingsView } from "./SettingsView";
|
||||
import { FancyModal } from "../../Modal";
|
||||
import { ModalView, NotificationItem, NotificationModalProps } from "../types";
|
||||
import {
|
||||
fetchRssFeed,
|
||||
formatDate,
|
||||
getAllFeeds,
|
||||
getCategoryColor,
|
||||
getCategoryLabel,
|
||||
getSourceName,
|
||||
} from "../utils";
|
||||
|
||||
export function NotificationModal({ id }: NotificationModalProps) {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [readNotifications, setReadNotifications] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [currentView, setCurrentView] = useState<ModalView>("list");
|
||||
const [selectedNotification, setSelectedNotification] =
|
||||
useState<NotificationItem | null>(null);
|
||||
const [isShiftHeld, setIsShiftHeld] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Settings state
|
||||
const [autoReadDays, setAutoReadDays] = useState<number>(14);
|
||||
const [customFeeds, setCustomFeeds] = useState<string[]>([]);
|
||||
|
||||
// Load read notifications and settings from localStorage
|
||||
useEffect(() => {
|
||||
const savedRead = localStorage.getItem("read-notifications");
|
||||
if (savedRead) {
|
||||
try {
|
||||
const readArray = JSON.parse(savedRead);
|
||||
setReadNotifications(new Set(readArray));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse read notifications:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Load settings
|
||||
const savedAutoReadDays = localStorage.getItem(
|
||||
"notification-auto-read-days",
|
||||
);
|
||||
if (savedAutoReadDays) {
|
||||
try {
|
||||
setAutoReadDays(parseInt(savedAutoReadDays, 10));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse auto read days:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const savedCustomFeeds = localStorage.getItem("notification-custom-feeds");
|
||||
if (savedCustomFeeds) {
|
||||
try {
|
||||
setCustomFeeds(JSON.parse(savedCustomFeeds));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse custom feeds:", e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle shift key for mark all as unread button
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Shift") {
|
||||
setIsShiftHeld(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Shift") {
|
||||
setIsShiftHeld(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch RSS feed function
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const allNotifications: NotificationItem[] = [];
|
||||
const autoReadGuids: string[] = [];
|
||||
|
||||
// Mark notifications older than autoReadDays as read
|
||||
const autoReadDate = new Date();
|
||||
autoReadDate.setDate(autoReadDate.getDate() - autoReadDays);
|
||||
|
||||
// Get all feeds (default + custom)
|
||||
const feeds = getAllFeeds();
|
||||
|
||||
// Fetch from all feeds
|
||||
for (const feedUrl of feeds) {
|
||||
if (!feedUrl.trim()) continue;
|
||||
|
||||
try {
|
||||
const xmlText = await fetchRssFeed(feedUrl);
|
||||
|
||||
// Basic validation that we got XML content
|
||||
if (
|
||||
xmlText &&
|
||||
(xmlText.includes("<rss") || xmlText.includes("<feed"))
|
||||
) {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
||||
|
||||
// Check for parsing errors
|
||||
const parserError = xmlDoc.querySelector("parsererror");
|
||||
if (!parserError && xmlDoc && xmlDoc.documentElement) {
|
||||
// Handle both RSS (item) and Atom (entry) feeds
|
||||
const items = xmlDoc.querySelectorAll("item, entry");
|
||||
if (items && items.length > 0) {
|
||||
items.forEach((item) => {
|
||||
try {
|
||||
// Handle both RSS and Atom formats
|
||||
const guid =
|
||||
item.querySelector("guid")?.textContent ||
|
||||
item.querySelector("id")?.textContent ||
|
||||
"";
|
||||
const title =
|
||||
item.querySelector("title")?.textContent || "";
|
||||
const link =
|
||||
item.querySelector("link")?.textContent ||
|
||||
item.querySelector("link")?.getAttribute("href") ||
|
||||
"";
|
||||
const description =
|
||||
item.querySelector("description")?.textContent ||
|
||||
item.querySelector("content")?.textContent ||
|
||||
item.querySelector("summary")?.textContent ||
|
||||
"";
|
||||
const pubDate =
|
||||
item.querySelector("pubDate")?.textContent ||
|
||||
item.querySelector("published")?.textContent ||
|
||||
item.querySelector("updated")?.textContent ||
|
||||
"";
|
||||
const category =
|
||||
item.querySelector("category")?.textContent || "";
|
||||
|
||||
// Skip items without essential data
|
||||
// Use link as fallback for guid if guid is missing
|
||||
const itemGuid = guid || link;
|
||||
if (!itemGuid || !title) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the publication date
|
||||
const notificationDate = new Date(pubDate);
|
||||
|
||||
allNotifications.push({
|
||||
guid: itemGuid,
|
||||
title,
|
||||
link,
|
||||
description,
|
||||
pubDate,
|
||||
category,
|
||||
source: getSourceName(feedUrl),
|
||||
});
|
||||
|
||||
// Collect GUIDs of notifications older than autoReadDays
|
||||
if (notificationDate <= autoReadDate) {
|
||||
autoReadGuids.push(itemGuid);
|
||||
}
|
||||
} catch (itemError) {
|
||||
// Skip malformed items
|
||||
console.warn(
|
||||
"Skipping malformed RSS/Atom item:",
|
||||
itemError,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (customFeedError) {
|
||||
// Silently fail for individual feed errors
|
||||
}
|
||||
}
|
||||
|
||||
setNotifications(allNotifications);
|
||||
|
||||
// Update read notifications after setting notifications
|
||||
if (autoReadGuids.length > 0) {
|
||||
setReadNotifications((prevReadSet) => {
|
||||
const newReadSet = new Set(prevReadSet);
|
||||
autoReadGuids.forEach((guid) => newReadSet.add(guid));
|
||||
|
||||
// Update localStorage
|
||||
localStorage.setItem(
|
||||
"read-notifications",
|
||||
JSON.stringify(Array.from(newReadSet)),
|
||||
);
|
||||
|
||||
return newReadSet;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("RSS fetch error:", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load notifications",
|
||||
);
|
||||
// Set empty notifications to prevent crashes
|
||||
setNotifications([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [autoReadDays]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, [fetchNotifications]);
|
||||
|
||||
// Refresh function
|
||||
const handleRefresh = () => {
|
||||
fetchNotifications();
|
||||
};
|
||||
|
||||
// Save read notifications to cookie
|
||||
const markAsRead = (guid: string) => {
|
||||
const newReadSet = new Set(readNotifications);
|
||||
newReadSet.add(guid);
|
||||
setReadNotifications(newReadSet);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem(
|
||||
"read-notifications",
|
||||
JSON.stringify(Array.from(newReadSet)),
|
||||
);
|
||||
};
|
||||
|
||||
// Mark all as read
|
||||
const markAllAsRead = () => {
|
||||
const allGuids = notifications.map((n) => n.guid);
|
||||
const newReadSet = new Set(allGuids);
|
||||
setReadNotifications(newReadSet);
|
||||
localStorage.setItem(
|
||||
"read-notifications",
|
||||
JSON.stringify(Array.from(newReadSet)),
|
||||
);
|
||||
};
|
||||
|
||||
// Mark all as unread
|
||||
const markAllAsUnread = () => {
|
||||
setReadNotifications(new Set());
|
||||
localStorage.setItem("read-notifications", JSON.stringify([]));
|
||||
};
|
||||
|
||||
// Navigate to detail view
|
||||
const openNotificationDetail = (notification: NotificationItem) => {
|
||||
setSelectedNotification(notification);
|
||||
setCurrentView("detail");
|
||||
markAsRead(notification.guid);
|
||||
};
|
||||
|
||||
// Navigate back to list
|
||||
const goBackToList = () => {
|
||||
setCurrentView("list");
|
||||
setSelectedNotification(null);
|
||||
};
|
||||
|
||||
// Settings functions
|
||||
const openSettings = () => {
|
||||
setCurrentView("settings");
|
||||
};
|
||||
|
||||
const closeSettings = () => {
|
||||
setCurrentView("list");
|
||||
};
|
||||
|
||||
// Save settings functions
|
||||
const saveAutoReadDays = (days: number) => {
|
||||
setAutoReadDays(days);
|
||||
localStorage.setItem("notification-auto-read-days", days.toString());
|
||||
};
|
||||
|
||||
const saveCustomFeeds = (feeds: string[]) => {
|
||||
setCustomFeeds(feeds);
|
||||
localStorage.setItem("notification-custom-feeds", JSON.stringify(feeds));
|
||||
};
|
||||
|
||||
// Scroll to last read notification
|
||||
useEffect(() => {
|
||||
if (
|
||||
notifications.length > 0 &&
|
||||
containerRef.current &&
|
||||
currentView === "list"
|
||||
) {
|
||||
const lastReadIndex = notifications.findIndex(
|
||||
(n) => !readNotifications.has(n.guid),
|
||||
);
|
||||
if (lastReadIndex > 0) {
|
||||
const element = containerRef.current.children[
|
||||
lastReadIndex
|
||||
] as HTMLElement;
|
||||
if (element) {
|
||||
// Use scrollTop instead of scrollIntoView to avoid scrolling the modal container
|
||||
const container = containerRef.current;
|
||||
const elementTop = element.offsetTop;
|
||||
const containerHeight = container.clientHeight;
|
||||
const elementHeight = element.clientHeight;
|
||||
|
||||
// Calculate the scroll position to center the element
|
||||
const scrollTop =
|
||||
elementTop - containerHeight / 2 + elementHeight / 2;
|
||||
|
||||
container.scrollTo({
|
||||
top: Math.max(0, scrollTop),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [notifications, readNotifications, currentView]);
|
||||
|
||||
const unreadCount = notifications.filter(
|
||||
(n) => !readNotifications.has(n.guid),
|
||||
).length;
|
||||
|
||||
// Don't render if there's a critical error
|
||||
if (error && !loading) {
|
||||
return (
|
||||
<FancyModal id={id} title="Notifications" size="lg">
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Icon icon={Icons.WARNING} className="text-[2rem] text-red-400" />
|
||||
<p className="text-red-400 mb-2">Failed to load notifications</p>
|
||||
<p className="text-sm text-type-secondary">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="mt-4 text-sm text-type-link hover:text-type-linkHover transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</FancyModal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FancyModal
|
||||
id={id}
|
||||
title={
|
||||
currentView === "list"
|
||||
? "Notifications"
|
||||
: currentView === "detail" && selectedNotification
|
||||
? selectedNotification.title
|
||||
: currentView === "settings"
|
||||
? "Settings"
|
||||
: "Notifications"
|
||||
}
|
||||
size="lg"
|
||||
>
|
||||
{currentView === "list" ? (
|
||||
<ListView
|
||||
notifications={notifications}
|
||||
readNotifications={readNotifications}
|
||||
unreadCount={unreadCount}
|
||||
loading={loading}
|
||||
error={error}
|
||||
containerRef={containerRef}
|
||||
markAllAsRead={markAllAsRead}
|
||||
markAllAsUnread={markAllAsUnread}
|
||||
isShiftHeld={isShiftHeld}
|
||||
onRefresh={handleRefresh}
|
||||
onOpenSettings={openSettings}
|
||||
openNotificationDetail={openNotificationDetail}
|
||||
getCategoryColor={getCategoryColor}
|
||||
getCategoryLabel={getCategoryLabel}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
) : currentView === "detail" && selectedNotification ? (
|
||||
<DetailView
|
||||
selectedNotification={selectedNotification}
|
||||
goBackToList={goBackToList}
|
||||
getCategoryColor={getCategoryColor}
|
||||
getCategoryLabel={getCategoryLabel}
|
||||
formatDate={formatDate}
|
||||
isRead={readNotifications.has(selectedNotification.guid)}
|
||||
toggleReadStatus={() => {
|
||||
if (readNotifications.has(selectedNotification.guid)) {
|
||||
// Mark as unread
|
||||
const newReadSet = new Set(readNotifications);
|
||||
newReadSet.delete(selectedNotification.guid);
|
||||
setReadNotifications(newReadSet);
|
||||
localStorage.setItem(
|
||||
"read-notifications",
|
||||
JSON.stringify(Array.from(newReadSet)),
|
||||
);
|
||||
} else {
|
||||
// Mark as read
|
||||
markAsRead(selectedNotification.guid);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : currentView === "settings" ? (
|
||||
<SettingsView
|
||||
autoReadDays={autoReadDays}
|
||||
setAutoReadDays={saveAutoReadDays}
|
||||
customFeeds={customFeeds}
|
||||
setCustomFeeds={saveCustomFeeds}
|
||||
markAllAsUnread={markAllAsUnread}
|
||||
onClose={closeSettings}
|
||||
/>
|
||||
) : null}
|
||||
</FancyModal>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
import { SettingsViewProps } from "../types";
|
||||
|
||||
export function SettingsView({
|
||||
autoReadDays,
|
||||
setAutoReadDays,
|
||||
customFeeds,
|
||||
setCustomFeeds,
|
||||
markAllAsUnread,
|
||||
onClose,
|
||||
}: SettingsViewProps) {
|
||||
const addCustomFeed = () => {
|
||||
setCustomFeeds([...customFeeds, ""]);
|
||||
};
|
||||
|
||||
const changeCustomFeed = (index: number, val: string) => {
|
||||
setCustomFeeds(
|
||||
customFeeds.map((v, i) => {
|
||||
if (i !== index) return v;
|
||||
return val;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const removeCustomFeed = (index: number) => {
|
||||
setCustomFeeds(customFeeds.filter((v, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with back button */}
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-utils-divider">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-type-link hover:text-type-linkHover transition-colors flex items-center gap-1 text-sm"
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_LEFT} />
|
||||
<span>Back to notifications</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Settings content */}
|
||||
<div className="space-y-6 overflow-y-auto max-h-[70vh] md:max-h-[60vh] md:pr-2">
|
||||
{/* Mark all as unread section */}
|
||||
<div className="bg-background-main/30 rounded-lg p-4 border border-utils-divider">
|
||||
<h3 className="text-white font-bold mb-3">Mark All as Unread</h3>
|
||||
<p className="text-sm text-type-secondary mb-4">
|
||||
Permanently mark all notifications as unread. This action cannot be
|
||||
undone.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAllAsUnread}
|
||||
className="text-sm text-red-400 hover:text-red-300 transition-colors px-3 py-1 rounded-md border border-red-400/30 hover:border-red-400/50"
|
||||
>
|
||||
Mark All as Unread
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-read days section */}
|
||||
<div className="bg-background-main/30 rounded-lg p-4 border border-utils-divider">
|
||||
<h3 className="text-white font-bold mb-3">Auto-Mark as Read</h3>
|
||||
<p className="text-sm text-type-secondary mb-4">
|
||||
Automatically mark notifications as read after this many days.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={autoReadDays}
|
||||
onChange={(e) =>
|
||||
setAutoReadDays(parseInt(e.target.value, 10) || 14)
|
||||
}
|
||||
className="bg-background-secondary border border-type-secondary rounded px-3 py-2 text-white w-20"
|
||||
/>
|
||||
<span className="text-sm text-type-secondary">days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom feeds section */}
|
||||
<div className="bg-background-main/30 rounded-lg p-4 border border-utils-divider">
|
||||
<h3 className="text-white font-bold mb-3">Custom RSS Feeds</h3>
|
||||
<p className="text-sm text-type-secondary mb-4">
|
||||
Add custom RSS feeds to receive notifications from other sources.
|
||||
<br />
|
||||
<span className="text-sm text-type-danger">
|
||||
Note: This feature is experimental and may not work for all feeds.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 max-w-md">
|
||||
{customFeeds.length === 0 ? (
|
||||
<p className="text-sm text-type-secondary">
|
||||
No custom feeds added
|
||||
</p>
|
||||
) : null}
|
||||
{customFeeds.map((feed, i) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
className="grid grid-cols-[1fr,auto] items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
value={feed}
|
||||
onChange={(e) => changeCustomFeed(i, e.target.value)}
|
||||
placeholder="https://example.com/feed.xml"
|
||||
className="bg-background-secondary border border-type-secondary rounded px-3 py-2 text-white text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCustomFeed(i)}
|
||||
className="h-full scale-90 hover:scale-100 rounded-full aspect-square bg-authentication-inputBg hover:bg-authentication-inputBgHover flex justify-center items-center transition-transform duration-200 hover:text-white cursor-pointer"
|
||||
>
|
||||
<Icon className="text-xl" icon={Icons.X} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomFeed}
|
||||
className="mt-3 text-sm text-type-link hover:text-type-linkHover transition-colors px-3 py-1 rounded-md border border-type-link/30 hover:border-type-link/50"
|
||||
>
|
||||
Add Custom Feed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recommended feeds section */}
|
||||
<div className="bg-background-main/30 rounded-lg p-4 border border-utils-divider">
|
||||
<h3 className="text-white font-bold mb-3">Recommended Feeds</h3>
|
||||
<code className="text-type-secondary text-xs md:text-sm">
|
||||
https://www.moviefone.com/feeds/movie-news.rss
|
||||
<br />
|
||||
https://www.moviefone.com/feeds/tv-news.rss
|
||||
<br />
|
||||
https://www.filmjabber.com/rss/rss-dvd-reviews.php
|
||||
<br />
|
||||
https://screenrant.com/feed/
|
||||
<br />
|
||||
https://www.darkhorizons.com/feed/
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||
|
||||
import { NotificationItem } from "../types";
|
||||
import { fetchRssFeed, getAllFeeds, getSourceName } from "../utils";
|
||||
|
||||
// Hook to manage notifications
|
||||
export function useNotifications() {
|
||||
const { showModal, hideModal, isModalVisible } = useOverlayStack();
|
||||
const modalId = "notifications";
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
|
||||
// Fetch notifications for badge count
|
||||
useEffect(() => {
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const allNotifications: NotificationItem[] = [];
|
||||
|
||||
// Get all feeds (default + custom)
|
||||
const feeds = getAllFeeds();
|
||||
|
||||
// Fetch from all feeds
|
||||
for (const feedUrl of feeds) {
|
||||
if (!feedUrl.trim()) continue;
|
||||
|
||||
try {
|
||||
const xmlText = await fetchRssFeed(feedUrl);
|
||||
|
||||
// Basic validation that we got XML content
|
||||
if (
|
||||
xmlText &&
|
||||
(xmlText.includes("<rss") || xmlText.includes("<feed"))
|
||||
) {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
||||
|
||||
// Check for parsing errors
|
||||
const parserError = xmlDoc.querySelector("parsererror");
|
||||
if (!parserError && xmlDoc && xmlDoc.documentElement) {
|
||||
// Handle both RSS (item) and Atom (entry) feeds
|
||||
const items = xmlDoc.querySelectorAll("item, entry");
|
||||
if (items && items.length > 0) {
|
||||
items.forEach((item) => {
|
||||
try {
|
||||
// Handle both RSS and Atom formats
|
||||
const guid =
|
||||
item.querySelector("guid")?.textContent ||
|
||||
item.querySelector("id")?.textContent ||
|
||||
"";
|
||||
const title =
|
||||
item.querySelector("title")?.textContent || "";
|
||||
const link =
|
||||
item.querySelector("link")?.textContent ||
|
||||
item.querySelector("link")?.getAttribute("href") ||
|
||||
"";
|
||||
const description =
|
||||
item.querySelector("description")?.textContent ||
|
||||
item.querySelector("content")?.textContent ||
|
||||
item.querySelector("summary")?.textContent ||
|
||||
"";
|
||||
const pubDate =
|
||||
item.querySelector("pubDate")?.textContent ||
|
||||
item.querySelector("published")?.textContent ||
|
||||
item.querySelector("updated")?.textContent ||
|
||||
"";
|
||||
const category =
|
||||
item.querySelector("category")?.textContent || "";
|
||||
|
||||
// Skip items without essential data
|
||||
// Use link as fallback for guid if guid is missing
|
||||
const itemGuid = guid || link;
|
||||
if (!itemGuid || !title) {
|
||||
return;
|
||||
}
|
||||
|
||||
allNotifications.push({
|
||||
guid: itemGuid,
|
||||
title,
|
||||
link,
|
||||
description,
|
||||
pubDate,
|
||||
category,
|
||||
source: getSourceName(feedUrl),
|
||||
});
|
||||
} catch (itemError) {
|
||||
// Skip malformed items silently
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (customFeedError) {
|
||||
// Silently fail for individual feed errors
|
||||
}
|
||||
}
|
||||
|
||||
setNotifications(allNotifications);
|
||||
} catch (err) {
|
||||
// Silently fail for badge count
|
||||
}
|
||||
};
|
||||
|
||||
fetchNotifications();
|
||||
}, []);
|
||||
|
||||
const openNotifications = () => {
|
||||
showModal(modalId);
|
||||
};
|
||||
|
||||
const closeNotifications = () => {
|
||||
hideModal(modalId);
|
||||
};
|
||||
|
||||
const isNotificationsOpen = () => {
|
||||
return isModalVisible(modalId);
|
||||
};
|
||||
|
||||
// Get unread count for badge
|
||||
const getUnreadCount = () => {
|
||||
try {
|
||||
const savedRead = localStorage.getItem("read-notifications");
|
||||
if (!savedRead) {
|
||||
const count = notifications.length;
|
||||
return count > 99 ? "99+" : count;
|
||||
}
|
||||
|
||||
const readArray = JSON.parse(savedRead);
|
||||
const readSet = new Set(readArray);
|
||||
|
||||
// Get the actual count from the notifications state
|
||||
const count = notifications.filter(
|
||||
(n: NotificationItem) => !readSet.has(n.guid),
|
||||
).length;
|
||||
|
||||
return count > 99 ? "99+" : count;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
openNotifications,
|
||||
closeNotifications,
|
||||
isNotificationsOpen,
|
||||
getUnreadCount,
|
||||
};
|
||||
}
|
||||
28
src/components/overlays/notificationsModal/index.ts
Normal file
28
src/components/overlays/notificationsModal/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Components
|
||||
export { NotificationModal } from "./components/NotificationModal";
|
||||
export { DetailView } from "./components/DetailView";
|
||||
export { ListView } from "./components/ListView";
|
||||
export { SettingsView } from "./components/SettingsView";
|
||||
|
||||
// Hooks
|
||||
export { useNotifications } from "./hooks/useNotifications";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
NotificationItem,
|
||||
NotificationModalProps,
|
||||
ModalView,
|
||||
DetailViewProps,
|
||||
SettingsViewProps,
|
||||
ListViewProps,
|
||||
} from "./types";
|
||||
|
||||
// Utils
|
||||
export {
|
||||
getAllFeeds,
|
||||
getFetchUrl,
|
||||
getSourceName,
|
||||
formatDate,
|
||||
getCategoryColor,
|
||||
getCategoryLabel,
|
||||
} from "./utils";
|
||||
52
src/components/overlays/notificationsModal/types/index.ts
Normal file
52
src/components/overlays/notificationsModal/types/index.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export interface NotificationItem {
|
||||
guid: string;
|
||||
title: string;
|
||||
link: string;
|
||||
description: string;
|
||||
pubDate: string;
|
||||
category: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface NotificationModalProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type ModalView = "list" | "detail" | "settings";
|
||||
|
||||
export interface DetailViewProps {
|
||||
selectedNotification: NotificationItem;
|
||||
goBackToList: () => void;
|
||||
getCategoryColor: (category: string) => string;
|
||||
getCategoryLabel: (category: string) => string;
|
||||
formatDate: (dateString: string) => string;
|
||||
isRead: boolean;
|
||||
toggleReadStatus: () => void;
|
||||
}
|
||||
|
||||
export interface SettingsViewProps {
|
||||
autoReadDays: number;
|
||||
setAutoReadDays: (days: number) => void;
|
||||
customFeeds: string[];
|
||||
setCustomFeeds: (feeds: string[]) => void;
|
||||
markAllAsUnread: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface ListViewProps {
|
||||
notifications: NotificationItem[];
|
||||
readNotifications: Set<string>;
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
markAllAsRead: () => void;
|
||||
markAllAsUnread: () => void;
|
||||
isShiftHeld: boolean;
|
||||
onRefresh: () => void;
|
||||
onOpenSettings: () => void;
|
||||
openNotificationDetail: (notification: NotificationItem) => void;
|
||||
getCategoryColor: (category: string) => string;
|
||||
getCategoryLabel: (category: string) => string;
|
||||
formatDate: (dateString: string) => string;
|
||||
}
|
||||
102
src/components/overlays/notificationsModal/utils/index.ts
Normal file
102
src/components/overlays/notificationsModal/utils/index.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { proxiedFetch } from "@/backend/helpers/fetch";
|
||||
|
||||
const DEFAULT_FEEDS = ["/notifications.xml"];
|
||||
|
||||
export const getAllFeeds = (): string[] => {
|
||||
try {
|
||||
const savedCustomFeeds = localStorage.getItem("notification-custom-feeds");
|
||||
if (savedCustomFeeds) {
|
||||
const customFeeds = JSON.parse(savedCustomFeeds);
|
||||
return [...DEFAULT_FEEDS, ...customFeeds];
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail and return default feeds
|
||||
}
|
||||
return DEFAULT_FEEDS;
|
||||
};
|
||||
|
||||
export const getFetchUrl = (feedUrl: string): string => {
|
||||
if (feedUrl.startsWith("/")) {
|
||||
return feedUrl;
|
||||
}
|
||||
return feedUrl;
|
||||
};
|
||||
|
||||
// New function to fetch RSS feeds using proxiedFetch
|
||||
export const fetchRssFeed = async (feedUrl: string): Promise<string> => {
|
||||
if (feedUrl.startsWith("/")) {
|
||||
// For local feeds, use regular fetch
|
||||
const response = await fetch(feedUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
// For external feeds, use proxiedFetch
|
||||
const response = await proxiedFetch(feedUrl, {
|
||||
responseType: "text",
|
||||
});
|
||||
return response as string;
|
||||
};
|
||||
|
||||
export const getSourceName = (feedUrl: string): string => {
|
||||
if (feedUrl === "/notifications.xml") {
|
||||
return "P-Stream";
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(feedUrl);
|
||||
return url.hostname.replace("www.", "");
|
||||
} catch {
|
||||
return "Unknown";
|
||||
}
|
||||
};
|
||||
|
||||
export const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCategoryColor = (category: string) => {
|
||||
if (!category || category.trim() === "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (category.toLowerCase()) {
|
||||
case "announcement":
|
||||
return "bg-blue-500";
|
||||
case "feature":
|
||||
return "bg-green-500";
|
||||
case "update":
|
||||
return "bg-yellow-500";
|
||||
case "bugfix":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const getCategoryLabel = (category: string) => {
|
||||
switch (category.toLowerCase()) {
|
||||
case "announcement":
|
||||
return "Announcement";
|
||||
case "feature":
|
||||
return "New Feature";
|
||||
case "update":
|
||||
return "Update";
|
||||
case "bugfix":
|
||||
return "Bug Fix";
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
};
|
||||
|
|
@ -447,7 +447,7 @@ export function EpisodesView({
|
|||
E{ep.number}
|
||||
</span>
|
||||
{!isAired && (
|
||||
<span className="text-video-context-type-main/70 text-sm">
|
||||
<span className="bg-video-context-hoverColor/50 text-video-context-type-main/80 text-sm px-1 py-0.5 rounded-md">
|
||||
{ep.air_date
|
||||
? `(${t("details.airs")} - ${new Date(ep.air_date).toLocaleDateString()})`
|
||||
: `(${t("media.unreleased")})`}
|
||||
|
|
@ -575,7 +575,7 @@ export function EpisodesView({
|
|||
E{ep.number}
|
||||
</span>
|
||||
{!isAired && (
|
||||
<span className="text-video-context-type-main/70 text-sm">
|
||||
<span className="bg-video-context-hoverColor/50 text-video-context-type-main/80 text-sm px-1 py-0.5 rounded-md">
|
||||
{ep.air_date
|
||||
? `(${t("details.airs")} - ${new Date(ep.air_date).toLocaleDateString()})`
|
||||
: `(${t("media.unreleased")})`}
|
||||
|
|
|
|||
46
src/components/player/atoms/SpeedChangedPopout.tsx
Normal file
46
src/components/player/atoms/SpeedChangedPopout.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { t } from "i18next";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
import { Transition } from "@/components/utils/Transition";
|
||||
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
|
||||
export function SpeedChangedPopout() {
|
||||
const isSpeedBoosted = usePlayerStore((s) => s.interface.isSpeedBoosted);
|
||||
const showSpeedIndicator = usePlayerStore(
|
||||
(s) => s.interface.showSpeedIndicator,
|
||||
);
|
||||
const currentOverlay = useOverlayStack((s) => s.currentOverlay);
|
||||
const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate);
|
||||
|
||||
return (
|
||||
<Transition
|
||||
animation="slide-down"
|
||||
show={showSpeedIndicator && currentOverlay === "speed"}
|
||||
className="absolute inset-x-0 top-4 flex justify-center pointer-events-none"
|
||||
>
|
||||
<Flare.Base className="hover:flare-enabled pointer-events-auto bg-video-context-background pl-4 pr-6 py-3 group w-72 h-full rounded-lg transition-colors text-video-context-type-main">
|
||||
<Flare.Light
|
||||
enabled
|
||||
flareSize={200}
|
||||
cssColorVar="--colors-video-context-light"
|
||||
backgroundClass="bg-video-context-background duration-100"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
<Flare.Child className="grid grid-cols-[auto,1fr] gap-3 pointer-events-auto relative transition-transform">
|
||||
<Icon className="text-2xl" icon={Icons.TACHOMETER} />
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{isSpeedBoosted
|
||||
? t("player.menus.playback.speedBoosted")
|
||||
: t("player.menus.playback.speedUnboosted", {
|
||||
speed: playbackRate,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Flare.Child>
|
||||
</Flare.Base>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,3 +18,4 @@ export * from "./NextEpisodeButton";
|
|||
export * from "./Chromecast";
|
||||
export * from "./CastingNotification";
|
||||
export * from "./Captions";
|
||||
export * from "./SpeedChangedPopout";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Icon, Icons } from "@/components/Icon";
|
|||
import { useCaptions } from "@/components/player/hooks/useCaptions";
|
||||
import { Menu } from "@/components/player/internals/ContextMenu";
|
||||
import { SelectableLink } from "@/components/player/internals/ContextMenu/Links";
|
||||
import { fixUTF8Encoding } from "@/components/player/utils/captions";
|
||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { useSubtitleStore } from "@/stores/subtitles";
|
||||
|
|
@ -151,13 +152,14 @@ export function CustomCaptionOption() {
|
|||
if (!event.target || typeof event.target.result !== "string")
|
||||
return;
|
||||
|
||||
// Ensure the data is in UTF-8
|
||||
// Ensure the data is in UTF-8 and fix any encoding issues
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const utf8Bytes = encoder.encode(event.target.result);
|
||||
const utf8Data = decoder.decode(utf8Bytes);
|
||||
const fixedData = fixUTF8Encoding(utf8Data);
|
||||
|
||||
const converted = convert(utf8Data, "srt");
|
||||
const converted = convert(fixedData, "srt");
|
||||
setCaption({
|
||||
language: "custom",
|
||||
srtData: converted,
|
||||
|
|
@ -203,13 +205,14 @@ export function CaptionsView({
|
|||
reader.addEventListener("load", (e) => {
|
||||
if (!e.target || typeof e.target.result !== "string") return;
|
||||
|
||||
// Ensure the data is in UTF-8
|
||||
// Ensure the data is in UTF-8 and fix any encoding issues
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const utf8Bytes = encoder.encode(e.target.result);
|
||||
const utf8Data = decoder.decode(utf8Bytes);
|
||||
const fixedData = fixUTF8Encoding(utf8Data);
|
||||
|
||||
const converted = convert(utf8Data, "srt");
|
||||
const converted = convert(fixedData, "srt");
|
||||
|
||||
setCaption({
|
||||
language: "custom",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export function OpenSubtitlesCaptionView({
|
|||
const { selectCaptionById } = useCaptions();
|
||||
const captionList = usePlayerStore((s) => s.captionList);
|
||||
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
|
||||
const addExternalSubtitles = usePlayerStore((s) => s.addExternalSubtitles);
|
||||
|
||||
const captions = useMemo(
|
||||
() =>
|
||||
|
|
@ -48,6 +49,10 @@ export function OpenSubtitlesCaptionView({
|
|||
[selectCaptionById, setCurrentlyDownloading],
|
||||
);
|
||||
|
||||
const [refreshReq, startRefresh] = useAsyncFn(async () => {
|
||||
return addExternalSubtitles();
|
||||
}, [addExternalSubtitles]);
|
||||
|
||||
const content = subtitleList.length
|
||||
? subtitleList.map((v) => {
|
||||
return (
|
||||
|
|
@ -98,6 +103,14 @@ export function OpenSubtitlesCaptionView({
|
|||
<div className="p-4 rounded-xl bg-video-context-light bg-opacity-10 font-medium text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
{t("player.menus.subtitles.empty")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRefresh()}
|
||||
disabled={refreshReq.loading}
|
||||
className="p-1 w-3/4 rounded tabbable duration-200 bg-opacity-10 bg-video-context-light hover:bg-opacity-20"
|
||||
>
|
||||
{t("player.menus.subtitles.scrapeButton")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { DetailsModal } from "@/components/overlays/details/DetailsModal";
|
||||
import { DetailsModal } from "@/components/overlays/detailsModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
|
|
|
|||
|
|
@ -31,6 +31,17 @@ export function KeyboardEvents() {
|
|||
const volumeDebounce = useRef<ReturnType<typeof setTimeout> | undefined>();
|
||||
const subtitleDebounce = useRef<ReturnType<typeof setTimeout> | undefined>();
|
||||
|
||||
// Speed boost
|
||||
const setSpeedBoosted = usePlayerStore((s) => s.setSpeedBoosted);
|
||||
const setShowSpeedIndicator = usePlayerStore((s) => s.setShowSpeedIndicator);
|
||||
const speedIndicatorTimeoutRef = useRef<
|
||||
ReturnType<typeof setTimeout> | undefined
|
||||
>();
|
||||
const boostTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>();
|
||||
const isPendingBoostRef = useRef<boolean>(false);
|
||||
const previousRateRef = useRef<number>(1);
|
||||
const isSpaceHeldRef = useRef<boolean>(false);
|
||||
|
||||
const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay);
|
||||
|
||||
const dataRef = useRef({
|
||||
|
|
@ -51,6 +62,13 @@ export function KeyboardEvents() {
|
|||
setShowDelayIndicator,
|
||||
setCurrentOverlay,
|
||||
isInWatchParty,
|
||||
previousRateRef,
|
||||
isSpaceHeldRef,
|
||||
setSpeedBoosted,
|
||||
setShowSpeedIndicator,
|
||||
speedIndicatorTimeoutRef,
|
||||
boostTimeoutRef,
|
||||
isPendingBoostRef,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -72,6 +90,13 @@ export function KeyboardEvents() {
|
|||
setShowDelayIndicator,
|
||||
setCurrentOverlay,
|
||||
isInWatchParty,
|
||||
previousRateRef,
|
||||
isSpaceHeldRef,
|
||||
setSpeedBoosted,
|
||||
setShowSpeedIndicator,
|
||||
speedIndicatorTimeoutRef,
|
||||
boostTimeoutRef,
|
||||
isPendingBoostRef,
|
||||
};
|
||||
}, [
|
||||
setShowVolume,
|
||||
|
|
@ -91,10 +116,12 @@ export function KeyboardEvents() {
|
|||
setShowDelayIndicator,
|
||||
setCurrentOverlay,
|
||||
isInWatchParty,
|
||||
setSpeedBoosted,
|
||||
setShowSpeedIndicator,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyEventHandler = (evt: KeyboardEvent) => {
|
||||
const keydownEventHandler = (evt: KeyboardEvent) => {
|
||||
if (evt.target && (evt.target as HTMLInputElement).nodeName === "INPUT")
|
||||
return;
|
||||
|
||||
|
|
@ -132,6 +159,81 @@ export function KeyboardEvents() {
|
|||
if (next) dataRef.current.display?.setPlaybackRate(next);
|
||||
}
|
||||
|
||||
// Handle spacebar press for play/pause and hold for 2x speed - disabled in watch party
|
||||
if (k === " " && !dataRef.current.isInWatchParty) {
|
||||
// Skip if a button is targeted
|
||||
if (
|
||||
evt.target &&
|
||||
(evt.target as HTMLInputElement).nodeName === "BUTTON"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent the default spacebar behavior
|
||||
evt.preventDefault();
|
||||
|
||||
// If already paused, play the video and return
|
||||
if (dataRef.current.mediaPlaying.isPaused) {
|
||||
dataRef.current.display?.play();
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're already holding space, don't trigger boost again
|
||||
if (dataRef.current.isSpaceHeldRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current rate
|
||||
dataRef.current.previousRateRef.current =
|
||||
dataRef.current.mediaPlaying.playbackRate;
|
||||
|
||||
// Set pending boost flag
|
||||
dataRef.current.isPendingBoostRef.current = true;
|
||||
|
||||
// Add delay before boosting speed
|
||||
if (dataRef.current.boostTimeoutRef.current) {
|
||||
clearTimeout(dataRef.current.boostTimeoutRef.current);
|
||||
}
|
||||
|
||||
dataRef.current.boostTimeoutRef.current = setTimeout(() => {
|
||||
// Only apply boost if the key is still held down
|
||||
if (dataRef.current.isPendingBoostRef.current) {
|
||||
dataRef.current.isSpaceHeldRef.current = true;
|
||||
dataRef.current.isPendingBoostRef.current = false;
|
||||
|
||||
// Show speed indicator
|
||||
dataRef.current.setSpeedBoosted(true);
|
||||
dataRef.current.setShowSpeedIndicator(true);
|
||||
dataRef.current.setCurrentOverlay("speed");
|
||||
|
||||
// Clear any existing timeout
|
||||
if (dataRef.current.speedIndicatorTimeoutRef.current) {
|
||||
clearTimeout(dataRef.current.speedIndicatorTimeoutRef.current);
|
||||
}
|
||||
|
||||
dataRef.current.display?.setPlaybackRate(2);
|
||||
}
|
||||
}, 300); // 300ms delay before boost takes effect
|
||||
}
|
||||
|
||||
// Handle spacebar press for play/pause only in watch party mode
|
||||
if (k === " " && dataRef.current.isInWatchParty) {
|
||||
// Skip if a button is targeted
|
||||
if (
|
||||
evt.target &&
|
||||
(evt.target as HTMLInputElement).nodeName === "BUTTON"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent the default spacebar behavior
|
||||
evt.preventDefault();
|
||||
|
||||
// Simple play/pause toggle
|
||||
const action = dataRef.current.mediaPlaying.isPaused ? "play" : "pause";
|
||||
dataRef.current.display?.[action]();
|
||||
}
|
||||
|
||||
// Video progress
|
||||
if (k === "ArrowRight")
|
||||
dataRef.current.display?.setTime(dataRef.current.time + 5);
|
||||
|
|
@ -148,7 +250,10 @@ export function KeyboardEvents() {
|
|||
|
||||
// Utils
|
||||
if (keyL === "f") dataRef.current.display?.toggleFullscreen();
|
||||
if (k === " " || keyL === "k") {
|
||||
|
||||
// Remove duplicate spacebar handler that was conflicting
|
||||
// with our improved implementation
|
||||
if (keyL === "k" && !dataRef.current.isSpaceHeldRef.current) {
|
||||
if (
|
||||
evt.target &&
|
||||
(evt.target as HTMLInputElement).nodeName === "BUTTON"
|
||||
|
|
@ -193,10 +298,53 @@ export function KeyboardEvents() {
|
|||
}, 3000);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", keyEventHandler);
|
||||
|
||||
const keyupEventHandler = (evt: KeyboardEvent) => {
|
||||
const k = evt.key;
|
||||
|
||||
// Handle spacebar release - only handle speed boost logic when not in watch party
|
||||
if (k === " " && !dataRef.current.isInWatchParty) {
|
||||
// If we haven't applied the boost yet but were about to, cancel it
|
||||
if (dataRef.current.isPendingBoostRef.current) {
|
||||
dataRef.current.isPendingBoostRef.current = false;
|
||||
if (dataRef.current.boostTimeoutRef.current) {
|
||||
clearTimeout(dataRef.current.boostTimeoutRef.current);
|
||||
}
|
||||
|
||||
// The space key was released quickly, so trigger play/pause
|
||||
const action = dataRef.current.mediaPlaying.isPaused
|
||||
? "play"
|
||||
: "pause";
|
||||
dataRef.current.display?.[action]();
|
||||
} else if (dataRef.current.isSpaceHeldRef.current) {
|
||||
// We were in boost mode, restore previous rate
|
||||
dataRef.current.display?.setPlaybackRate(
|
||||
dataRef.current.previousRateRef.current,
|
||||
);
|
||||
dataRef.current.isSpaceHeldRef.current = false;
|
||||
|
||||
// Update UI state
|
||||
dataRef.current.setSpeedBoosted(false);
|
||||
|
||||
// Set a timeout to hide the speed indicator
|
||||
if (dataRef.current.speedIndicatorTimeoutRef.current) {
|
||||
clearTimeout(dataRef.current.speedIndicatorTimeoutRef.current);
|
||||
}
|
||||
|
||||
dataRef.current.speedIndicatorTimeoutRef.current = setTimeout(() => {
|
||||
dataRef.current.setShowSpeedIndicator(false);
|
||||
dataRef.current.setCurrentOverlay(null);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", keydownEventHandler);
|
||||
window.addEventListener("keyup", keyupEventHandler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", keyEventHandler);
|
||||
window.removeEventListener("keydown", keydownEventHandler);
|
||||
window.removeEventListener("keyup", keyupEventHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import classNames from "classnames";
|
||||
import { PointerEvent, useCallback } from "react";
|
||||
import { PointerEvent, useCallback, useRef, useState } from "react";
|
||||
import { useEffectOnce, useTimeoutFn } from "react-use";
|
||||
|
||||
import { useShouldShowVideoElement } from "@/components/player/internals/VideoContainer";
|
||||
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||
import { PlayerHoverState } from "@/stores/player/slices/interface";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
|
||||
|
|
@ -10,10 +11,15 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
|
|||
const show = useShouldShowVideoElement();
|
||||
const display = usePlayerStore((s) => s.display);
|
||||
const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused);
|
||||
const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate);
|
||||
const updateInterfaceHovering = usePlayerStore(
|
||||
(s) => s.updateInterfaceHovering,
|
||||
);
|
||||
const setSpeedBoosted = usePlayerStore((s) => s.setSpeedBoosted);
|
||||
const setShowSpeedIndicator = usePlayerStore((s) => s.setShowSpeedIndicator);
|
||||
const hovering = usePlayerStore((s) => s.interface.hovering);
|
||||
const setCurrentOverlay = useOverlayStack((s) => s.setCurrentOverlay);
|
||||
|
||||
const [_, cancel, reset] = useTimeoutFn(() => {
|
||||
updateInterfaceHovering(PlayerHoverState.NOT_HOVERING);
|
||||
}, 3000);
|
||||
|
|
@ -21,12 +27,31 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
|
|||
cancel();
|
||||
});
|
||||
|
||||
const previousRateRef = useRef(playbackRate);
|
||||
const isHoldingRef = useRef(false);
|
||||
const speedIndicatorTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const boostTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isPendingBoost, setIsPendingBoost] = useState(false);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
display?.toggleFullscreen();
|
||||
}, [display]);
|
||||
|
||||
const togglePause = useCallback(
|
||||
(e: PointerEvent<HTMLDivElement>) => {
|
||||
// Don't toggle pause if holding for speed change
|
||||
if (isHoldingRef.current) {
|
||||
isHoldingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any pending boost if we're clicking to pause
|
||||
if (isPendingBoost) {
|
||||
clearTimeout(boostTimeoutRef.current!);
|
||||
setIsPendingBoost(false);
|
||||
isHoldingRef.current = false;
|
||||
}
|
||||
|
||||
// pause on mouse click
|
||||
if (e.pointerType === "mouse") {
|
||||
if (e.button !== 0) return;
|
||||
|
|
@ -44,9 +69,136 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
|
|||
cancel();
|
||||
}
|
||||
},
|
||||
[display, isPaused, hovering, updateInterfaceHovering, reset, cancel],
|
||||
[
|
||||
display,
|
||||
isPaused,
|
||||
hovering,
|
||||
updateInterfaceHovering,
|
||||
reset,
|
||||
cancel,
|
||||
isPendingBoost,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: PointerEvent<HTMLDivElement>) => {
|
||||
if (e.pointerType === "mouse" && e.button === 0 && !isPaused) {
|
||||
// Store current rate before changing
|
||||
previousRateRef.current = playbackRate;
|
||||
|
||||
// Set a timeout before actually boosting speed
|
||||
if (boostTimeoutRef.current) {
|
||||
clearTimeout(boostTimeoutRef.current);
|
||||
}
|
||||
|
||||
setIsPendingBoost(true);
|
||||
|
||||
boostTimeoutRef.current = setTimeout(() => {
|
||||
// Only apply boost if we're still holding down
|
||||
isHoldingRef.current = true;
|
||||
setIsPendingBoost(false);
|
||||
|
||||
// Show speed indicator
|
||||
setSpeedBoosted(true);
|
||||
setShowSpeedIndicator(true);
|
||||
setCurrentOverlay("speed");
|
||||
|
||||
if (speedIndicatorTimeoutRef.current) {
|
||||
clearTimeout(speedIndicatorTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set to 2x speed
|
||||
display?.setPlaybackRate(2);
|
||||
}, 300); // 300ms delay before boost takes effect
|
||||
}
|
||||
},
|
||||
[
|
||||
display,
|
||||
playbackRate,
|
||||
isPaused,
|
||||
setSpeedBoosted,
|
||||
setShowSpeedIndicator,
|
||||
setCurrentOverlay,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e: PointerEvent<HTMLDivElement>) => {
|
||||
// If we have a pending boost that hasn't activated yet, clear it
|
||||
if (isPendingBoost) {
|
||||
clearTimeout(boostTimeoutRef.current!);
|
||||
setIsPendingBoost(false);
|
||||
togglePause(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHoldingRef.current && e.pointerType === "mouse" && e.button === 0) {
|
||||
// Restore previous rate
|
||||
display?.setPlaybackRate(previousRateRef.current);
|
||||
isHoldingRef.current = false;
|
||||
|
||||
// Update state for speed indicator
|
||||
setSpeedBoosted(false);
|
||||
|
||||
// Set a timeout to hide the speed indicator
|
||||
if (speedIndicatorTimeoutRef.current) {
|
||||
clearTimeout(speedIndicatorTimeoutRef.current);
|
||||
}
|
||||
|
||||
speedIndicatorTimeoutRef.current = setTimeout(() => {
|
||||
setShowSpeedIndicator(false);
|
||||
setCurrentOverlay(null);
|
||||
speedIndicatorTimeoutRef.current = null;
|
||||
}, 1500);
|
||||
} else {
|
||||
// Regular click handler
|
||||
togglePause(e);
|
||||
}
|
||||
},
|
||||
[
|
||||
display,
|
||||
togglePause,
|
||||
setSpeedBoosted,
|
||||
setShowSpeedIndicator,
|
||||
setCurrentOverlay,
|
||||
isPendingBoost,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle case where mouse leaves the player while still pressed
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
// Clear pending boost if mouse leaves
|
||||
if (isPendingBoost) {
|
||||
clearTimeout(boostTimeoutRef.current!);
|
||||
setIsPendingBoost(false);
|
||||
}
|
||||
|
||||
if (isHoldingRef.current) {
|
||||
display?.setPlaybackRate(previousRateRef.current);
|
||||
isHoldingRef.current = false;
|
||||
|
||||
// Update state for speed indicator
|
||||
setSpeedBoosted(false);
|
||||
|
||||
// Set a timeout to hide the speed indicator
|
||||
if (speedIndicatorTimeoutRef.current) {
|
||||
clearTimeout(speedIndicatorTimeoutRef.current);
|
||||
}
|
||||
|
||||
speedIndicatorTimeoutRef.current = setTimeout(() => {
|
||||
setShowSpeedIndicator(false);
|
||||
setCurrentOverlay(null);
|
||||
speedIndicatorTimeoutRef.current = null;
|
||||
}, 1500);
|
||||
}
|
||||
}, [
|
||||
display,
|
||||
setSpeedBoosted,
|
||||
setShowSpeedIndicator,
|
||||
setCurrentOverlay,
|
||||
isPendingBoost,
|
||||
]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -56,7 +208,9 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
|
|||
"cursor-none": !props.showingControls,
|
||||
})}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
onPointerUp={togglePause}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,63 @@ import { CaptionListItem } from "@/stores/player/slices/source";
|
|||
export type CaptionCueType = ContentCaption;
|
||||
export const sanitize = DOMPurify.sanitize;
|
||||
|
||||
// UTF-8 character mapping for fixing corrupted special characters
|
||||
const utf8Map: Record<string, string> = {
|
||||
"ä": "ä",
|
||||
"Ä": "Ä",
|
||||
"ä": "ä",
|
||||
"Ä": "Ä",
|
||||
"ö": "ö",
|
||||
"ö": "ö",
|
||||
"Ã¥": "å",
|
||||
"Ã¥": "å",
|
||||
"é": "é",
|
||||
"é": "é",
|
||||
ú: "ú",
|
||||
ú: "ú",
|
||||
"ñ": "ñ",
|
||||
"ñ": "ñ",
|
||||
"á": "á",
|
||||
"á": "á",
|
||||
"ÃÂ": "í",
|
||||
"Ã": "í",
|
||||
"ó": "ó",
|
||||
"ó": "ó",
|
||||
"ü": "ü",
|
||||
"ü": "ü",
|
||||
"ç": "ç",
|
||||
"ç": "ç",
|
||||
"è": "è",
|
||||
"è": "è",
|
||||
"ì": "ì",
|
||||
"ì": "ì",
|
||||
"ò": "ò",
|
||||
"ò": "ò",
|
||||
"ù": "ù",
|
||||
"ù": "ù",
|
||||
ÃÂ: "à",
|
||||
Ã: "à",
|
||||
"Â": "",
|
||||
Â: "",
|
||||
"Â ": "",
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixes UTF-8 encoding issues in subtitle text
|
||||
* Handles common cases where special characters and accents get corrupted
|
||||
*
|
||||
* Example:
|
||||
* Input: "Hyvä on, ohjelma oli tässä."
|
||||
* Output: "Hyvä on, ohjelma oli tässä."
|
||||
*/
|
||||
export function fixUTF8Encoding(text: string): string {
|
||||
let fixedText = text;
|
||||
Object.keys(utf8Map).forEach((bad) => {
|
||||
fixedText = fixedText.split(bad).join(utf8Map[bad]);
|
||||
});
|
||||
return fixedText;
|
||||
}
|
||||
|
||||
export function captionIsVisible(
|
||||
start: number,
|
||||
end: number,
|
||||
|
|
@ -31,7 +88,9 @@ export function convertSubtitlesToVtt(text: string): string {
|
|||
if (textTrimmed === "") {
|
||||
throw new Error("Given text is empty");
|
||||
}
|
||||
const vtt = convert(textTrimmed, "vtt");
|
||||
// Fix UTF-8 encoding issues before conversion
|
||||
const fixedText = fixUTF8Encoding(textTrimmed);
|
||||
const vtt = convert(fixedText, "vtt");
|
||||
if (detect(vtt) === "") {
|
||||
throw new Error("Invalid subtitle format");
|
||||
}
|
||||
|
|
@ -43,7 +102,9 @@ export function convertSubtitlesToSrt(text: string): string {
|
|||
if (textTrimmed === "") {
|
||||
throw new Error("Given text is empty");
|
||||
}
|
||||
const srt = convert(textTrimmed, "srt");
|
||||
// Fix UTF-8 encoding issues before conversion
|
||||
const fixedText = fixUTF8Encoding(textTrimmed);
|
||||
const srt = convert(fixedText, "srt");
|
||||
if (detect(srt) === "") {
|
||||
throw new Error("Invalid subtitle format");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
keysFromMnemonic,
|
||||
signChallenge,
|
||||
} from "@/backend/accounts/crypto";
|
||||
import { getGroupOrder } from "@/backend/accounts/groupOrder";
|
||||
import { importBookmarks, importProgress } from "@/backend/accounts/import";
|
||||
import { getLoginChallengeToken, loginAccount } from "@/backend/accounts/login";
|
||||
import { progressMediaItemToInputs } from "@/backend/accounts/progress";
|
||||
|
|
@ -180,13 +181,21 @@ export function useAuth() {
|
|||
throw err;
|
||||
}
|
||||
|
||||
const [bookmarks, progress, settings] = await Promise.all([
|
||||
const [bookmarks, progress, settings, groupOrder] = await Promise.all([
|
||||
getBookmarks(backendUrl, account),
|
||||
getProgress(backendUrl, account),
|
||||
getSettings(backendUrl, account),
|
||||
getGroupOrder(backendUrl, account),
|
||||
]);
|
||||
|
||||
syncData(user.user, user.session, progress, bookmarks, settings);
|
||||
syncData(
|
||||
user.user,
|
||||
user.session,
|
||||
progress,
|
||||
bookmarks,
|
||||
settings,
|
||||
groupOrder,
|
||||
);
|
||||
},
|
||||
[backendUrl, syncData, logout],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "@/backend/accounts/user";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { useGroupOrderStore } from "@/stores/groupOrder";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
|
|
@ -24,6 +25,7 @@ export function useAuthData() {
|
|||
const setProxySet = useAuthStore((s) => s.setProxySet);
|
||||
const clearBookmarks = useBookmarkStore((s) => s.clear);
|
||||
const clearProgress = useProgressStore((s) => s.clear);
|
||||
const clearGroupOrder = useGroupOrderStore((s) => s.clear);
|
||||
const setTheme = useThemeStore((s) => s.setTheme);
|
||||
const setAppLanguage = useLanguageStore((s) => s.setLanguage);
|
||||
const importSubtitleLanguage = useSubtitleStore(
|
||||
|
|
@ -86,8 +88,15 @@ export function useAuthData() {
|
|||
removeAccount();
|
||||
clearBookmarks();
|
||||
clearProgress();
|
||||
clearGroupOrder();
|
||||
setFebboxKey(null);
|
||||
}, [removeAccount, clearBookmarks, clearProgress, setFebboxKey]);
|
||||
}, [
|
||||
removeAccount,
|
||||
clearBookmarks,
|
||||
clearProgress,
|
||||
clearGroupOrder,
|
||||
setFebboxKey,
|
||||
]);
|
||||
|
||||
const syncData = useCallback(
|
||||
async (
|
||||
|
|
@ -96,10 +105,15 @@ export function useAuthData() {
|
|||
progress: ProgressResponse[],
|
||||
bookmarks: BookmarkResponse[],
|
||||
settings: SettingsResponse,
|
||||
groupOrder: { groupOrder: string[] },
|
||||
) => {
|
||||
replaceBookmarks(bookmarkResponsesToEntries(bookmarks));
|
||||
replaceItems(progressResponsesToEntries(progress));
|
||||
|
||||
if (groupOrder?.groupOrder) {
|
||||
useGroupOrderStore.getState().setGroupOrder(groupOrder.groupOrder);
|
||||
}
|
||||
|
||||
if (settings.applicationLanguage) {
|
||||
setAppLanguage(settings.applicationLanguage);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import App from "@/setup/App";
|
|||
import { conf } from "@/setup/config";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { BookmarkSyncer } from "@/stores/bookmarks/BookmarkSyncer";
|
||||
import { GroupSyncer } from "@/stores/groupOrder/GroupSyncer";
|
||||
import { changeAppLanguage, useLanguageStore } from "@/stores/language";
|
||||
import { ProgressSyncer } from "@/stores/progress/ProgressSyncer";
|
||||
import { SettingsSyncer } from "@/stores/subtitles/SettingsSyncer";
|
||||
|
|
@ -185,6 +186,7 @@ root.render(
|
|||
<ThemeProvider applyGlobal>
|
||||
<ProgressSyncer />
|
||||
<BookmarkSyncer />
|
||||
<GroupSyncer />
|
||||
<SettingsSyncer />
|
||||
<TheRouter>
|
||||
<MigrationRunner />
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { To, useNavigate } from "react-router-dom";
|
||||
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { DetailsModal } from "@/components/overlays/details/DetailsModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { DetailsModal } from "@/components/overlays/detailsModal";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
|
|
@ -21,6 +20,7 @@ import { WatchingPart } from "@/pages/parts/home/WatchingPart";
|
|||
import { SearchListPart } from "@/pages/parts/search/SearchListPart";
|
||||
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||
import { usePreferencesStore } from "@/stores/preferences";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ export function HomePage() {
|
|||
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||
const [showWatching, setShowWatching] = useState(false);
|
||||
const [detailsData, setDetailsData] = useState<any>();
|
||||
const detailsModal = useModal("details");
|
||||
const { showModal } = useOverlayStack();
|
||||
const enableDiscover = usePreferencesStore((state) => state.enableDiscover);
|
||||
const enableFeatured = usePreferencesStore((state) => state.enableFeatured);
|
||||
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
|
|
@ -84,7 +84,7 @@ export function HomePage() {
|
|||
id: Number(media.id),
|
||||
type: media.type === "movie" ? "movie" : "show",
|
||||
});
|
||||
detailsModal.show();
|
||||
showModal("details");
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -102,7 +102,7 @@ export function HomePage() {
|
|||
{/* Page Header */}
|
||||
{enableFeatured ? (
|
||||
<FeaturedCarousel
|
||||
forcedCategory="editorpicks"
|
||||
forcedCategory="movies"
|
||||
onShowDetails={handleShowDetails}
|
||||
searching={s.searching}
|
||||
shorter
|
||||
|
|
|
|||
439
src/pages/bookmarks/AllBookmarks.tsx
Normal file
439
src/pages/bookmarks/AllBookmarks.tsx
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { EditButton } from "@/components/buttons/EditButton";
|
||||
import { EditButtonWithText } from "@/components/buttons/EditButtonWithText";
|
||||
import { Item } from "@/components/form/SortableList";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { MediaGrid } from "@/components/media/MediaGrid";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import { DetailsModal } from "@/components/overlays/detailsModal";
|
||||
import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { UserIcon, UserIcons } from "@/components/UserIcon";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
|
||||
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { useGroupOrderStore } from "@/stores/groupOrder";
|
||||
import { useOverlayStack } from "@/stores/interface/overlayStack";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
function parseGroupString(group: string): { icon: UserIcons; name: string } {
|
||||
const match = group.match(/^\[([a-zA-Z0-9_]+)\](.*)$/);
|
||||
if (match) {
|
||||
const iconKey = match[1].toUpperCase() as keyof typeof UserIcons;
|
||||
const icon = UserIcons[iconKey] || UserIcons.BOOKMARK;
|
||||
const name = match[2].trim();
|
||||
return { icon, name };
|
||||
}
|
||||
return { icon: UserIcons.BOOKMARK, name: group };
|
||||
}
|
||||
|
||||
interface AllBookmarksProps {
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
}
|
||||
|
||||
export function AllBookmarks({ onShowDetails }: AllBookmarksProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t: randomT } = useRandomTranslation();
|
||||
const emptyText = randomT(`home.search.empty`);
|
||||
const navigate = useNavigate();
|
||||
const progressItems = useProgressStore((s) => s.items);
|
||||
const bookmarks = useBookmarkStore((s) => s.bookmarks);
|
||||
const groupOrder = useGroupOrderStore((s) => s.groupOrder);
|
||||
const setGroupOrder = useGroupOrderStore((s) => s.setGroupOrder);
|
||||
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const editOrderModal = useModal("bookmark-edit-order-all");
|
||||
const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]);
|
||||
const backendUrl = useBackendUrl();
|
||||
const account = useAuthStore((s) => s.account);
|
||||
const [detailsData, setDetailsData] = useState<any>();
|
||||
const { showModal } = useOverlayStack();
|
||||
|
||||
const handleShowDetails = async (media: MediaItem) => {
|
||||
if (onShowDetails) {
|
||||
onShowDetails(media);
|
||||
} else {
|
||||
setDetailsData({
|
||||
id: Number(media.id),
|
||||
type: media.type === "movie" ? "movie" : "show",
|
||||
});
|
||||
showModal("details");
|
||||
}
|
||||
};
|
||||
|
||||
const items = useMemo(() => {
|
||||
let output: MediaItem[] = [];
|
||||
Object.entries(bookmarks).forEach((entry) => {
|
||||
output.push({
|
||||
id: entry[0],
|
||||
...entry[1],
|
||||
});
|
||||
});
|
||||
output = output.sort((a, b) => {
|
||||
const bookmarkA = bookmarks[a.id];
|
||||
const bookmarkB = bookmarks[b.id];
|
||||
const progressA = progressItems[a.id];
|
||||
const progressB = progressItems[b.id];
|
||||
|
||||
const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0);
|
||||
const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0);
|
||||
|
||||
return dateB - dateA;
|
||||
});
|
||||
return output;
|
||||
}, [bookmarks, progressItems]);
|
||||
|
||||
const { groupedItems, regularItems } = useMemo(() => {
|
||||
const grouped: Record<string, MediaItem[]> = {};
|
||||
const regular: MediaItem[] = [];
|
||||
|
||||
items.forEach((item) => {
|
||||
const bookmark = bookmarks[item.id];
|
||||
if (Array.isArray(bookmark?.group)) {
|
||||
bookmark.group.forEach((groupName) => {
|
||||
if (!grouped[groupName]) {
|
||||
grouped[groupName] = [];
|
||||
}
|
||||
grouped[groupName].push(item);
|
||||
});
|
||||
} else {
|
||||
regular.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort items within each group by date
|
||||
Object.keys(grouped).forEach((group) => {
|
||||
grouped[group].sort((a, b) => {
|
||||
const bookmarkA = bookmarks[a.id];
|
||||
const bookmarkB = bookmarks[b.id];
|
||||
const progressA = progressItems[a.id];
|
||||
const progressB = progressItems[b.id];
|
||||
|
||||
const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0);
|
||||
const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0);
|
||||
|
||||
return dateB - dateA;
|
||||
});
|
||||
});
|
||||
|
||||
return { groupedItems: grouped, regularItems: regular };
|
||||
}, [items, bookmarks, progressItems]);
|
||||
|
||||
// group sorting
|
||||
const allGroups = useMemo(() => {
|
||||
const groups = new Set<string>();
|
||||
|
||||
Object.values(bookmarks).forEach((bookmark) => {
|
||||
if (Array.isArray(bookmark.group)) {
|
||||
bookmark.group.forEach((group) => groups.add(group));
|
||||
}
|
||||
});
|
||||
|
||||
groups.add("bookmarks");
|
||||
|
||||
return Array.from(groups);
|
||||
}, [bookmarks]);
|
||||
|
||||
const sortableItems = useMemo(() => {
|
||||
const currentOrder = editOrderModal.isShown ? tempGroupOrder : groupOrder;
|
||||
|
||||
if (currentOrder.length === 0) {
|
||||
return allGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}
|
||||
|
||||
const orderMap = new Map(
|
||||
currentOrder.map((group, index) => [group, index]),
|
||||
);
|
||||
const sortedGroups = allGroups.sort((groupA, groupB) => {
|
||||
const orderA = orderMap.has(groupA)
|
||||
? orderMap.get(groupA)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const orderB = orderMap.has(groupB)
|
||||
? orderMap.get(groupB)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return sortedGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}, [allGroups, t, editOrderModal.isShown, tempGroupOrder, groupOrder]);
|
||||
|
||||
const sortedSections = useMemo(() => {
|
||||
const sections: Array<{
|
||||
type: "grouped" | "regular";
|
||||
group?: string;
|
||||
items: MediaItem[];
|
||||
}> = [];
|
||||
|
||||
const allSections = new Map<string, MediaItem[]>();
|
||||
|
||||
Object.entries(groupedItems).forEach(([group, groupItems]) => {
|
||||
allSections.set(group, groupItems);
|
||||
});
|
||||
|
||||
if (regularItems.length > 0) {
|
||||
allSections.set("bookmarks", regularItems);
|
||||
}
|
||||
|
||||
if (groupOrder.length === 0) {
|
||||
allSections.forEach((sectionItems, group) => {
|
||||
if (group === "bookmarks") {
|
||||
sections.push({ type: "regular", items: sectionItems });
|
||||
} else {
|
||||
sections.push({ type: "grouped", group, items: sectionItems });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const orderMap = new Map(
|
||||
groupOrder.map((group, index) => [group, index]),
|
||||
);
|
||||
|
||||
Array.from(allSections.entries())
|
||||
.sort(([groupA], [groupB]) => {
|
||||
const orderA = orderMap.has(groupA)
|
||||
? orderMap.get(groupA)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const orderB = orderMap.has(groupB)
|
||||
? orderMap.get(groupB)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
})
|
||||
.forEach(([group, sectionItems]) => {
|
||||
if (group === "bookmarks") {
|
||||
sections.push({ type: "regular", items: sectionItems });
|
||||
} else {
|
||||
sections.push({ type: "grouped", group, items: sectionItems });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}, [groupedItems, regularItems, groupOrder]);
|
||||
|
||||
const handleEditGroupOrder = () => {
|
||||
// Initialize with current order or default order
|
||||
if (groupOrder.length === 0) {
|
||||
const defaultOrder = allGroups.map((group) => group);
|
||||
setTempGroupOrder(defaultOrder);
|
||||
} else {
|
||||
setTempGroupOrder([...groupOrder]);
|
||||
}
|
||||
editOrderModal.show();
|
||||
};
|
||||
|
||||
const handleReorderClick = () => {
|
||||
handleEditGroupOrder();
|
||||
// Keep editing state active by setting it to true
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleCancelOrder = () => {
|
||||
editOrderModal.hide();
|
||||
};
|
||||
|
||||
const handleSaveOrderClick = () => {
|
||||
setGroupOrder(tempGroupOrder);
|
||||
editOrderModal.hide();
|
||||
|
||||
// Save to backend
|
||||
if (backendUrl && account) {
|
||||
useGroupOrderStore
|
||||
.getState()
|
||||
.saveGroupOrderToBackend(backendUrl, account);
|
||||
}
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<WideContainer>
|
||||
<div className="flex flex-col items-center justify-center translate-y-1/2">
|
||||
<p className="text-[18.5px] pb-3">{emptyText}</p>
|
||||
<Button
|
||||
theme="purple"
|
||||
onClick={() => navigate("/")}
|
||||
className="mt-4"
|
||||
>
|
||||
{t("notFound.goHome")}
|
||||
</Button>
|
||||
</div>
|
||||
</WideContainer>
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<WideContainer>
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<Heading1 className="text-2xl font-bold text-white">
|
||||
{t("home.bookmarks.sectionTitle")}
|
||||
</Heading1>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing && allGroups.length > 1 && (
|
||||
<EditButtonWithText
|
||||
editing={editing}
|
||||
onEdit={handleReorderClick}
|
||||
id="edit-group-order-button-all"
|
||||
text={t("home.bookmarks.groups.reorder.button")}
|
||||
secondaryText={t("home.bookmarks.groups.reorder.done")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/")}
|
||||
className="flex items-center text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<Icon icon={Icons.ARROW_LEFT} className="text-xl" />
|
||||
<span className="ml-2">{t("discover.page.back")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`relative ${editOrderModal.isShown ? "pointer-events-none" : ""}`}
|
||||
>
|
||||
{/* Grouped Bookmarks */}
|
||||
{sortedSections.map((section) => {
|
||||
if (section.type === "grouped") {
|
||||
const { icon, name } = parseGroupString(section.group || "");
|
||||
return (
|
||||
<div key={section.group || "bookmarks"} className="mb-6">
|
||||
<SectionHeading
|
||||
title={name}
|
||||
customIcon={
|
||||
<span className="w-6 h-6 flex items-center justify-center">
|
||||
<UserIcon icon={icon} className="w-full h-full" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing && allGroups.length > 1 && (
|
||||
<EditButtonWithText
|
||||
editing={editing}
|
||||
onEdit={handleReorderClick}
|
||||
id="edit-group-order-button"
|
||||
text={t("home.bookmarks.groups.reorder.button")}
|
||||
secondaryText={t(
|
||||
"home.bookmarks.groups.reorder.done",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
id={`edit-button-bookmark-${section.group}`}
|
||||
/>
|
||||
</div>
|
||||
</SectionHeading>
|
||||
<MediaGrid>
|
||||
{section.items.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
style={{ userSelect: "none" }}
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
<WatchedMediaCard
|
||||
media={v}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(v.id)}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</MediaGrid>
|
||||
</div>
|
||||
);
|
||||
} // regular items
|
||||
return (
|
||||
<div key="regular-bookmarks" className="mb-6">
|
||||
<SectionHeading
|
||||
title={t("home.bookmarks.sectionTitle")}
|
||||
icon={Icons.BOOKMARK}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing && allGroups.length > 1 && (
|
||||
<EditButtonWithText
|
||||
editing={editing}
|
||||
onEdit={handleReorderClick}
|
||||
id="edit-group-order-button"
|
||||
text={t("home.bookmarks.groups.reorder.button")}
|
||||
secondaryText={t("home.bookmarks.groups.reorder.done")}
|
||||
/>
|
||||
)}
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
id="edit-button-bookmark"
|
||||
/>
|
||||
</div>
|
||||
</SectionHeading>
|
||||
<MediaGrid ref={gridRef}>
|
||||
{section.items.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
style={{ userSelect: "none" }}
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
<WatchedMediaCard
|
||||
media={v}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(v.id)}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</MediaGrid>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Edit Order Modal */}
|
||||
<EditGroupOrderModal
|
||||
id={editOrderModal.id}
|
||||
isShown={editOrderModal.isShown}
|
||||
items={sortableItems}
|
||||
onCancel={handleCancelOrder}
|
||||
onSave={handleSaveOrderClick}
|
||||
onItemsChange={(newItems) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
setTempGroupOrder(newOrder);
|
||||
}}
|
||||
/>
|
||||
|
||||
{detailsData && <DetailsModal id="details" data={detailsData} />}
|
||||
</WideContainer>
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { TmdbMovie, getLetterboxdLists } from "@/backend/metadata/letterboxd";
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { MediaCard } from "@/components/media/MediaCard";
|
||||
import { DetailsModal } from "@/components/overlays/details/DetailsModal";
|
||||
import { DetailsModal } from "@/components/overlays/detailsModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
|
|
@ -26,6 +26,11 @@ export function DiscoverMore() {
|
|||
const { lastView } = useDiscoverStore();
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
// Track overflow states for Letterboxd lists
|
||||
const [overflowStates, setOverflowStates] = useState<{
|
||||
[key: string]: boolean;
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLetterboxdLists = async () => {
|
||||
try {
|
||||
|
|
@ -63,6 +68,41 @@ export function DiscoverMore() {
|
|||
}
|
||||
};
|
||||
|
||||
// Function to check overflow for a carousel
|
||||
const checkOverflow = (element: HTMLDivElement | null, key: string) => {
|
||||
if (!element) {
|
||||
setOverflowStates((prev) => ({ ...prev, [key]: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasOverflow = element.scrollWidth > element.clientWidth;
|
||||
setOverflowStates((prev) => ({ ...prev, [key]: hasOverflow }));
|
||||
};
|
||||
|
||||
// Function to set carousel ref and check overflow
|
||||
const setCarouselRef = (element: HTMLDivElement | null, key: string) => {
|
||||
carouselRefs.current[key] = element;
|
||||
|
||||
// Check overflow after a short delay to ensure content is rendered
|
||||
setTimeout(() => checkOverflow(element, key), 100);
|
||||
};
|
||||
|
||||
// Effect to recheck overflow on window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
// Recheck overflow for all carousels
|
||||
Object.keys(carouselRefs.current).forEach((key) => {
|
||||
const element = carouselRefs.current[key];
|
||||
if (element) {
|
||||
checkOverflow(element, key);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<WideContainer>
|
||||
|
|
@ -118,9 +158,7 @@ export function DiscoverMore() {
|
|||
<div className="relative overflow-hidden carousel-container md:pb-4">
|
||||
<div
|
||||
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
|
||||
ref={(el) => {
|
||||
carouselRefs.current[list.listUrl] = el;
|
||||
}}
|
||||
ref={(el) => setCarouselRef(el, list.listUrl)}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div className="md:w-12" />
|
||||
|
|
@ -152,6 +190,7 @@ export function DiscoverMore() {
|
|||
<CarouselNavButtons
|
||||
categorySlug={list.listUrl}
|
||||
carouselRefs={carouselRefs}
|
||||
hasOverflow={overflowStates[list.listUrl]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
import { DetailsModal } from "@/components/overlays/details/DetailsModal";
|
||||
import { DetailsModal } from "@/components/overlays/detailsModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
|
||||
import { SubPageLayout } from "../layouts/SubPageLayout";
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Icon, Icons } from "@/components/Icon";
|
|||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { MediaCard } from "@/components/media/MediaCard";
|
||||
import { MediaGrid } from "@/components/media/MediaGrid";
|
||||
import { DetailsModal } from "@/components/overlays/details/DetailsModal";
|
||||
import { DetailsModal } from "@/components/overlays/detailsModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ interface CarouselNavButtonsProps {
|
|||
carouselRefs: React.MutableRefObject<{
|
||||
[key: string]: HTMLDivElement | null;
|
||||
}>;
|
||||
hasOverflow?: boolean;
|
||||
}
|
||||
|
||||
interface NavButtonProps {
|
||||
|
|
@ -42,6 +43,7 @@ function NavButton({ direction, onClick }: NavButtonProps) {
|
|||
export function CarouselNavButtons({
|
||||
categorySlug,
|
||||
carouselRefs,
|
||||
hasOverflow = true,
|
||||
}: CarouselNavButtonsProps) {
|
||||
const handleScroll = (direction: "left" | "right") => {
|
||||
const carousel = carouselRefs.current[categorySlug];
|
||||
|
|
@ -74,6 +76,11 @@ export function CarouselNavButtons({
|
|||
});
|
||||
};
|
||||
|
||||
// Don't render buttons if there's no overflow
|
||||
if (!hasOverflow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavButton direction="left" onClick={() => handleScroll("left")} />
|
||||
|
|
|
|||
|
|
@ -142,12 +142,13 @@ export function FeaturedCarousel({
|
|||
const enableImageLogos = usePreferencesStore(
|
||||
(state) => state.enableImageLogos,
|
||||
);
|
||||
const userLanguage = useLanguageStore.getState().language;
|
||||
const userLanguage = useLanguageStore((s) => s.language);
|
||||
const formattedLanguage = getTmdbLanguageCode(userLanguage);
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
const [releaseInfo, setReleaseInfo] = useState<TraktReleaseResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [contentOpacity, setContentOpacity] = useState(1);
|
||||
|
||||
const currentMedia = media[currentIndex];
|
||||
|
||||
|
|
@ -198,7 +199,12 @@ export function FeaturedCarousel({
|
|||
useEffect(() => {
|
||||
const fetchFeaturedMedia = async () => {
|
||||
setIsLoading(true);
|
||||
setLogoUrl(undefined); // Clear logo when media changes
|
||||
// Clear all previous data when transitioning
|
||||
setLogoUrl(undefined);
|
||||
setImdbRatings({});
|
||||
setReleaseInfo(null);
|
||||
setCurrentIndex(0);
|
||||
setContentOpacity(1);
|
||||
if (logoFetchController.current) {
|
||||
logoFetchController.current.abort(); // Cancel any in-progress logo fetches
|
||||
}
|
||||
|
|
@ -372,7 +378,18 @@ export function FeaturedCarousel({
|
|||
}, [formattedLanguage, effectiveCategory]);
|
||||
|
||||
const handlePrevSlide = () => {
|
||||
setCurrentIndex((prev) => (prev - 1 + media.length) % media.length);
|
||||
setContentOpacity(0);
|
||||
setImdbRatings({});
|
||||
setReleaseInfo(null);
|
||||
|
||||
// Wait for fade out, then change index and fade in
|
||||
setTimeout(() => {
|
||||
setCurrentIndex((prev) => (prev - 1 + media.length) % media.length);
|
||||
// Clear logo after index change so new logo can load
|
||||
setLogoUrl(undefined);
|
||||
setTimeout(() => setContentOpacity(1), 100);
|
||||
}, 150);
|
||||
|
||||
// Reset autoplay timer
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
|
|
@ -385,7 +402,18 @@ export function FeaturedCarousel({
|
|||
};
|
||||
|
||||
const handleNextSlide = () => {
|
||||
setCurrentIndex((prev) => (prev + 1) % media.length);
|
||||
setContentOpacity(0);
|
||||
setImdbRatings({});
|
||||
setReleaseInfo(null);
|
||||
|
||||
// Wait for fade out, then change index and fade in
|
||||
setTimeout(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % media.length);
|
||||
// Clear logo after index change so new logo can load
|
||||
setLogoUrl(undefined);
|
||||
setTimeout(() => setContentOpacity(1), 100);
|
||||
}, 150);
|
||||
|
||||
// Reset autoplay timer
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
|
|
@ -482,7 +510,17 @@ export function FeaturedCarousel({
|
|||
useEffect(() => {
|
||||
if (isAutoPlaying && media.length > 0) {
|
||||
autoPlayInterval.current = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % media.length);
|
||||
setContentOpacity(0);
|
||||
setImdbRatings({});
|
||||
setReleaseInfo(null);
|
||||
|
||||
// Wait for fade out, then change index and fade in
|
||||
setTimeout(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % media.length);
|
||||
// Clear logo after index change so new logo can load
|
||||
setLogoUrl(undefined);
|
||||
setTimeout(() => setContentOpacity(1), 100);
|
||||
}, 150);
|
||||
}, SLIDE_DURATION);
|
||||
}
|
||||
|
||||
|
|
@ -639,7 +677,18 @@ export function FeaturedCarousel({
|
|||
key={`dot-${item.id}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCurrentIndex(index);
|
||||
setContentOpacity(0);
|
||||
setImdbRatings({});
|
||||
setReleaseInfo(null);
|
||||
|
||||
// Wait for fade out, then change index and fade in
|
||||
setTimeout(() => {
|
||||
setCurrentIndex(index);
|
||||
// Clear logo after index change so new logo can load
|
||||
setLogoUrl(undefined);
|
||||
setTimeout(() => setContentOpacity(1), 100);
|
||||
}, 150);
|
||||
|
||||
// Reset autoplay timer when clicking dots
|
||||
if (autoPlayInterval.current) {
|
||||
clearInterval(autoPlayInterval.current);
|
||||
|
|
@ -663,9 +712,10 @@ export function FeaturedCarousel({
|
|||
{/* Content Overlay */}
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute inset-0 flex items-end pb-20 z-10",
|
||||
"absolute inset-0 flex items-end pb-20 z-10 transition-opacity duration-150",
|
||||
searchClasses,
|
||||
)}
|
||||
style={{ opacity: contentOpacity }}
|
||||
>
|
||||
<div className="container mx-auto px-8 lg:px-4 flex justify-between items-end w-full">
|
||||
<div className="max-w-3xl">
|
||||
|
|
|
|||
|
|
@ -110,6 +110,9 @@ export function MediaCarousel({
|
|||
const { isMobile } = useIsMobile();
|
||||
const browser = !!window.chrome;
|
||||
|
||||
// Track overflow state
|
||||
const [hasOverflow, setHasOverflow] = useState(false);
|
||||
|
||||
// State for selected options
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
|
||||
const [selectedProviderName, setSelectedProviderName] = useState<string>("");
|
||||
|
|
@ -142,22 +145,26 @@ export function MediaCarousel({
|
|||
});
|
||||
|
||||
// Handle provider/genre selection
|
||||
const handleProviderChange = (id: string, name: string) => {
|
||||
const handleProviderChange = React.useCallback((id: string, name: string) => {
|
||||
setSelectedProviderId(id);
|
||||
setSelectedProviderName(name);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleGenreChange = (id: string, name: string) => {
|
||||
const handleGenreChange = React.useCallback((id: string, name: string) => {
|
||||
setSelectedGenreId(id);
|
||||
setSelectedGenreName(name);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get related buttons based on type
|
||||
const relatedButtons = showProviders
|
||||
? providers.map((p) => ({ id: p.id, name: p.name }))
|
||||
: showGenres
|
||||
? genres.map((g) => ({ id: g.id.toString(), name: g.name }))
|
||||
: undefined;
|
||||
const relatedButtons = React.useMemo(() => {
|
||||
if (showProviders) {
|
||||
return providers.map((p) => ({ id: p.id, name: p.name }));
|
||||
}
|
||||
if (showGenres) {
|
||||
return genres.map((g) => ({ id: g.id.toString(), name: g.name }));
|
||||
}
|
||||
return undefined;
|
||||
}, [showProviders, showGenres, providers, genres]);
|
||||
|
||||
// Set initial provider/genre selection
|
||||
useEffect(() => {
|
||||
|
|
@ -174,14 +181,16 @@ export function MediaCarousel({
|
|||
genres,
|
||||
selectedProviderId,
|
||||
selectedGenreId,
|
||||
handleProviderChange,
|
||||
handleGenreChange,
|
||||
]);
|
||||
|
||||
// Get the appropriate button click handler
|
||||
const onButtonClick = showProviders
|
||||
? handleProviderChange
|
||||
: showGenres
|
||||
? handleGenreChange
|
||||
: undefined;
|
||||
const onButtonClick = React.useMemo(() => {
|
||||
if (showProviders) return handleProviderChange;
|
||||
if (showGenres) return handleGenreChange;
|
||||
return undefined;
|
||||
}, [showProviders, showGenres, handleProviderChange, handleGenreChange]);
|
||||
|
||||
// Split buttons into visible and dropdown based on window width
|
||||
const { visibleButtons, dropdownButtons } = React.useMemo(() => {
|
||||
|
|
@ -195,14 +204,21 @@ export function MediaCarousel({
|
|||
}, [relatedButtons, windowWidth]);
|
||||
|
||||
// Determine content type and ID based on selection
|
||||
const contentType =
|
||||
showProviders && selectedProviderId
|
||||
? "provider"
|
||||
: showGenres && selectedGenreId
|
||||
? "genre"
|
||||
: showRecommendations && selectedRecommendationId
|
||||
? "recommendations"
|
||||
: content.type;
|
||||
const contentType = React.useMemo(() => {
|
||||
if (showProviders && selectedProviderId) return "provider";
|
||||
if (showGenres && selectedGenreId) return "genre";
|
||||
if (showRecommendations && selectedRecommendationId)
|
||||
return "recommendations";
|
||||
return content.type;
|
||||
}, [
|
||||
showProviders,
|
||||
selectedProviderId,
|
||||
showGenres,
|
||||
selectedGenreId,
|
||||
showRecommendations,
|
||||
selectedRecommendationId,
|
||||
content.type,
|
||||
]);
|
||||
|
||||
// Fetch media using our hook
|
||||
const { media, sectionTitle } = useDiscoverMedia({
|
||||
|
|
@ -217,17 +233,21 @@ export function MediaCarousel({
|
|||
});
|
||||
|
||||
// Find active button
|
||||
const activeButton = relatedButtons?.find(
|
||||
(btn) =>
|
||||
btn.name === selectedGenre?.name ||
|
||||
btn.name === sectionTitle.split(" on ")[1],
|
||||
);
|
||||
const activeButton = React.useMemo(() => {
|
||||
return relatedButtons?.find(
|
||||
(btn) =>
|
||||
btn.name === selectedGenre?.name ||
|
||||
btn.name === sectionTitle.split(" on ")[1],
|
||||
);
|
||||
}, [relatedButtons, selectedGenre?.name, sectionTitle]);
|
||||
|
||||
// Convert buttons to dropdown options
|
||||
const dropdownOptions: OptionItem[] = dropdownButtons.map((button) => ({
|
||||
id: button.id,
|
||||
name: button.name,
|
||||
}));
|
||||
const dropdownOptions: OptionItem[] = React.useMemo(() => {
|
||||
return dropdownButtons.map((button) => ({
|
||||
id: button.id,
|
||||
name: button.name,
|
||||
}));
|
||||
}, [dropdownButtons]);
|
||||
|
||||
// Set selected genre if active button is in dropdown
|
||||
React.useEffect(() => {
|
||||
|
|
@ -255,50 +275,100 @@ export function MediaCarousel({
|
|||
}
|
||||
}, [showRecommendations, recommendationSources, selectedRecommendationId]);
|
||||
|
||||
const categorySlug = `${sectionTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${isTVShow ? "tv" : "movie"}`;
|
||||
let isScrolling = false;
|
||||
const categorySlug = React.useMemo(() => {
|
||||
return `${sectionTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${isTVShow ? "tv" : "movie"}`;
|
||||
}, [sectionTitle, isTVShow]);
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (isScrolling) return;
|
||||
isScrolling = true;
|
||||
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
// Function to check overflow for the carousel
|
||||
const checkOverflow = React.useCallback((element: HTMLDivElement | null) => {
|
||||
if (!element) {
|
||||
setHasOverflow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
setTimeout(() => {
|
||||
isScrolling = false;
|
||||
}, 345);
|
||||
} else {
|
||||
isScrolling = false;
|
||||
}
|
||||
};
|
||||
const hasHorizontalOverflow = element.scrollWidth > element.clientWidth;
|
||||
setHasOverflow(hasHorizontalOverflow);
|
||||
}, []);
|
||||
|
||||
const handleMoreClick = () => {
|
||||
// Function to set carousel ref and check overflow
|
||||
const setCarouselRef = React.useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
carouselRefs.current[categorySlug] = element;
|
||||
|
||||
// Check overflow after a short delay to ensure content is rendered
|
||||
setTimeout(() => checkOverflow(element), 100);
|
||||
},
|
||||
[carouselRefs, categorySlug, checkOverflow],
|
||||
);
|
||||
|
||||
// Effect to recheck overflow on window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const element = carouselRefs.current[categorySlug];
|
||||
if (element) {
|
||||
checkOverflow(element);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [carouselRefs, categorySlug, checkOverflow]);
|
||||
const isScrollingRef = React.useRef(false);
|
||||
|
||||
const handleWheel = React.useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
if (isScrollingRef.current) return;
|
||||
isScrollingRef.current = true;
|
||||
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
setTimeout(() => {
|
||||
isScrollingRef.current = false;
|
||||
}, 345);
|
||||
} else {
|
||||
isScrollingRef.current = false;
|
||||
}
|
||||
},
|
||||
[browser],
|
||||
);
|
||||
|
||||
const handleMoreClick = React.useCallback(() => {
|
||||
setLastView({
|
||||
url: window.location.pathname,
|
||||
scrollPosition: window.scrollY,
|
||||
});
|
||||
};
|
||||
}, [setLastView]);
|
||||
|
||||
// Generate more link
|
||||
const generatedMoreLink =
|
||||
moreLink ||
|
||||
(() => {
|
||||
const baseLink = `/discover/more`;
|
||||
if (showProviders && selectedProviderId) {
|
||||
return `${baseLink}/provider/${selectedProviderId}/${mediaType}`;
|
||||
}
|
||||
if (showGenres && selectedGenreId) {
|
||||
return `${baseLink}/genre/${selectedGenreId}/${mediaType}`;
|
||||
}
|
||||
if (showRecommendations && selectedRecommendationId) {
|
||||
return `${baseLink}/recommendations/${selectedRecommendationId}/${mediaType}`;
|
||||
}
|
||||
return `${baseLink}/${content.type}/${mediaType}`;
|
||||
})();
|
||||
const generatedMoreLink = React.useMemo(() => {
|
||||
if (moreLink) return moreLink;
|
||||
|
||||
const baseLink = `/discover/more`;
|
||||
if (showProviders && selectedProviderId) {
|
||||
return `${baseLink}/provider/${selectedProviderId}/${mediaType}`;
|
||||
}
|
||||
if (showGenres && selectedGenreId) {
|
||||
return `${baseLink}/genre/${selectedGenreId}/${mediaType}`;
|
||||
}
|
||||
if (showRecommendations && selectedRecommendationId) {
|
||||
return `${baseLink}/recommendations/${selectedRecommendationId}/${mediaType}`;
|
||||
}
|
||||
return `${baseLink}/${content.type}/${mediaType}`;
|
||||
}, [
|
||||
moreLink,
|
||||
showProviders,
|
||||
selectedProviderId,
|
||||
showGenres,
|
||||
selectedGenreId,
|
||||
showRecommendations,
|
||||
selectedRecommendationId,
|
||||
mediaType,
|
||||
content.type,
|
||||
]);
|
||||
|
||||
// Loading state
|
||||
if (!isIntersecting || !sectionTitle) {
|
||||
|
|
@ -498,9 +568,7 @@ export function MediaCarousel({
|
|||
<div
|
||||
id={`carousel-${categorySlug}`}
|
||||
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
|
||||
ref={(el) => {
|
||||
carouselRefs.current[categorySlug] = el;
|
||||
}}
|
||||
ref={setCarouselRef}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div className="md:w-12" />
|
||||
|
|
@ -555,6 +623,7 @@ export function MediaCarousel({
|
|||
<CarouselNavButtons
|
||||
categorySlug={categorySlug}
|
||||
carouselRefs={carouselRefs}
|
||||
hasOverflow={hasOverflow}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function RandomMovieButton() {
|
|||
useState<NodeJS.Timeout | null>(null);
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const userLanguage = useLanguageStore.getState().language;
|
||||
const userLanguage = useLanguageStore((s) => s.language);
|
||||
const formattedLanguage = getTmdbLanguageCode(userLanguage);
|
||||
|
||||
// Fetch popular movies for random selection
|
||||
|
|
|
|||
|
|
@ -27,28 +27,30 @@ export function ScrollToTopButton() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50">
|
||||
<div className="fixed bottom-9 md:bottom-4 transform -translate-x-1/2 z-50 left-12 md:left-1/2">
|
||||
<div
|
||||
className={`absolute inset-0 mx-auto h-[50px] w-[200px] rounded-full blur-[50px] opacity-50 pointer-events-none z-0 ${
|
||||
isVisible ? "opacity-100 visible" : "opacity-0 invisible"
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`,
|
||||
transition: "opacity 0.4s ease-in-out, transform 0.4s ease-in-out",
|
||||
transition: "opacity 0.4s ease-in-out, transform 0.2s ease-in-out",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToTop}
|
||||
className={`relative backdrop-blur-sm flex items-center justify-center space-x-2 rounded-full px-4 py-3 text-lg font-semibold text-white bg-pill-background bg-opacity-80 hover:bg-pill-backgroundHover transition-opacity hover:scale-105 duration-500 ease-in-out ${
|
||||
className={`relative backdrop-blur-sm flex items-center justify-center space-x-2 rounded-full px-3 py-3 md:py-2 text-lg font-semibold text-white bg-pill-background bg-opacity-80 hover:bg-pill-backgroundHover transition-opacity hover:scale-105 duration-500 ease-in-out ${
|
||||
isVisible ? "opacity-100 visible" : "opacity-0 invisible"
|
||||
}`}
|
||||
style={{
|
||||
transition: "opacity 0.4s ease-in-out, transform 0.4s ease-in-out",
|
||||
transition: "opacity 0.4s ease-in-out, transform 0.2s ease-in-out",
|
||||
}}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_UP} className="text-2xl z-10" />
|
||||
<span className="z-10">{t("discover.scrollToTop")}</span>
|
||||
<span className="z-10 hidden md:block">
|
||||
{t("discover.scrollToTop")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
|
|||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { DetailsModal } from "@/components/overlays/details/DetailsModal";
|
||||
import { DetailsModal } from "@/components/overlays/detailsModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { useDiscoverStore } from "@/stores/discover";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ export function useDiscoverOptions(mediaType: MediaType) {
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const userLanguage = useLanguageStore.getState().language;
|
||||
const userLanguage = useLanguageStore((s) => s.language);
|
||||
const formattedLanguage = getTmdbLanguageCode(userLanguage);
|
||||
|
||||
const providers = mediaType === "movie" ? MOVIE_PROVIDERS : TV_PROVIDERS;
|
||||
|
|
@ -297,7 +297,7 @@ export function useDiscoverMedia({
|
|||
useState<string>(contentType);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const userLanguage = useLanguageStore.getState().language;
|
||||
const userLanguage = useLanguageStore((s) => s.language);
|
||||
const formattedLanguage = getTmdbLanguageCode(userLanguage);
|
||||
|
||||
// Reset media when content type or media type changes
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function useTMDBData(
|
|||
[categoryName: string]: Movie[] | TVShow[];
|
||||
}>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const userLanguage = useLanguageStore.getState().language;
|
||||
const userLanguage = useLanguageStore((s) => s.language);
|
||||
const formattedLanguage = getTmdbLanguageCode(userLanguage);
|
||||
|
||||
// Unified fetch function
|
||||
|
|
@ -108,7 +108,7 @@ export function useLazyTMDBData(
|
|||
) {
|
||||
const [media, setMedia] = useState<Movie[] | TVShow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const userLanguage = useLanguageStore.getState().language;
|
||||
const userLanguage = useLanguageStore((s) => s.language);
|
||||
const formattedLanguage = getTmdbLanguageCode(userLanguage);
|
||||
|
||||
const fetchMedia = useCallback(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import { Button } from "@/components/buttons/Button";
|
|||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Modal } from "@/components/overlays/Modal";
|
||||
import { DisplayError } from "@/components/player/display/displayInterface";
|
||||
import {
|
||||
formatErrorDebugInfo,
|
||||
gatherErrorDebugInfo,
|
||||
} from "@/utils/errorDebugInfo";
|
||||
|
||||
export function ErrorCard(props: {
|
||||
error: DisplayError | string;
|
||||
|
|
@ -25,7 +29,13 @@ export function ErrorCard(props: {
|
|||
|
||||
function copyError() {
|
||||
if (!props.error || !navigator.clipboard) return;
|
||||
navigator.clipboard.writeText(`\`\`\`${errorMessage}\`\`\``);
|
||||
|
||||
const debugInfo = gatherErrorDebugInfo(props.error);
|
||||
const formattedDebugInfo = formatErrorDebugInfo(debugInfo);
|
||||
|
||||
const fullErrorReport = `\`\`\`\n${errorMessage}\n\n${formattedDebugInfo}\n\`\`\``;
|
||||
|
||||
navigator.clipboard.writeText(fullErrorReport);
|
||||
|
||||
setHasCopied(true);
|
||||
|
||||
|
|
@ -57,7 +67,7 @@ export function ErrorCard(props: {
|
|||
<>
|
||||
<Icon icon={Icons.COPY} className="text-2xl" />
|
||||
<span className="hidden min-[400px]:inline-block ml-3">
|
||||
{t("actions.copy")}
|
||||
{t("player.playbackError.copyDebugInfo")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -74,7 +84,7 @@ export function ErrorCard(props: {
|
|||
<div className="pointer-events-auto mt-4 h-60 select-text overflow-y-auto whitespace-pre text-left">
|
||||
{errorMessage}
|
||||
</div>
|
||||
<p className="mt-4 text-sm">Check console for more details</p>
|
||||
<p className="mt-4 text-sm">{t("player.playbackError.debugInfo")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,37 @@
|
|||
import React, { useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { EditButton } from "@/components/buttons/EditButton";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { EditButtonWithText } from "@/components/buttons/EditButtonWithText";
|
||||
import { Item } from "@/components/form/SortableList";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { UserIcon, UserIcons } from "@/components/UserIcon";
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { useGroupOrderStore } from "@/stores/groupOrder";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
function parseGroupString(group: string): { icon: UserIcons; name: string } {
|
||||
const match = group.match(/^\[([a-zA-Z0-9_]+)\](.*)$/);
|
||||
if (match) {
|
||||
const iconKey = match[1].toUpperCase() as keyof typeof UserIcons;
|
||||
const icon = UserIcons[iconKey] || UserIcons.BOOKMARK;
|
||||
const name = match[2].trim();
|
||||
return { icon, name };
|
||||
}
|
||||
return { icon: UserIcons.BOOKMARK, name: group };
|
||||
}
|
||||
|
||||
interface BookmarksCarouselProps {
|
||||
carouselRefs: React.MutableRefObject<{
|
||||
[key: string]: HTMLDivElement | null;
|
||||
|
|
@ -19,6 +40,7 @@ interface BookmarksCarouselProps {
|
|||
}
|
||||
|
||||
const LONG_PRESS_DURATION = 500; // 0.5 seconds
|
||||
const MAX_ITEMS_PER_SECTION = 20; // Limit items per section
|
||||
|
||||
function MediaCardSkeleton() {
|
||||
return (
|
||||
|
|
@ -31,6 +53,36 @@ function MediaCardSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
function MoreBookmarksCard() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto">
|
||||
<Link to="/bookmarks" className="block">
|
||||
<Flare.Base className="group -m-[0.705em] h-[20rem] hover:scale-95 transition-all rounded-xl bg-background-main duration-300 hover:bg-mediaCard-hoverBackground tabbable">
|
||||
<Flare.Light
|
||||
flareSize={300}
|
||||
cssColorVar="--colors-mediaCard-hoverAccent"
|
||||
backgroundClass="bg-mediaCard-hoverBackground duration-100"
|
||||
className="rounded-xl bg-background-main group-hover:opacity-100"
|
||||
/>
|
||||
<Flare.Child className="pointer-events-auto h-[20rem] relative mb-2 p-[0.4em] transition-transform duration-300">
|
||||
<div className="flex absolute inset-0 flex-col items-center justify-center">
|
||||
<Icon
|
||||
icon={Icons.ARROW_RIGHT}
|
||||
className="text-4xl mb-2 transition-transform duration-300"
|
||||
/>
|
||||
<span className="text-sm text-center px-2">
|
||||
{t("home.bookmarks.showAll")}
|
||||
</span>
|
||||
</div>
|
||||
</Flare.Child>
|
||||
</Flare.Base>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookmarksCarousel({
|
||||
carouselRefs,
|
||||
onShowDetails,
|
||||
|
|
@ -41,6 +93,25 @@ export function BookmarksCarousel({
|
|||
const [editing, setEditing] = useState(false);
|
||||
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
|
||||
const pressTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const backendUrl = useBackendUrl();
|
||||
const account = useAuthStore((s) => s.account);
|
||||
|
||||
// Create refs for overflow detection
|
||||
const groupedCarouselRefs = useRef<{
|
||||
[key: string]: HTMLDivElement | null;
|
||||
}>({});
|
||||
const regularCarouselRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Track overflow state for each section
|
||||
const [overflowStates, setOverflowStates] = useState<{
|
||||
[key: string]: boolean;
|
||||
}>({});
|
||||
|
||||
// Group order editing state
|
||||
const groupOrder = useGroupOrderStore((s) => s.groupOrder);
|
||||
const setGroupOrder = useGroupOrderStore((s) => s.setGroupOrder);
|
||||
const editOrderModal = useModal("bookmark-edit-order-carousel");
|
||||
const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]);
|
||||
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
|
|
@ -73,6 +144,152 @@ export function BookmarksCarousel({
|
|||
return output;
|
||||
}, [bookmarks, progressItems]);
|
||||
|
||||
const { groupedItems, regularItems } = useMemo(() => {
|
||||
const grouped: Record<string, MediaItem[]> = {};
|
||||
const regular: MediaItem[] = [];
|
||||
|
||||
items.forEach((item) => {
|
||||
const bookmark = bookmarks[item.id];
|
||||
if (Array.isArray(bookmark?.group)) {
|
||||
bookmark.group.forEach((groupName) => {
|
||||
if (!grouped[groupName]) {
|
||||
grouped[groupName] = [];
|
||||
}
|
||||
grouped[groupName].push(item);
|
||||
});
|
||||
} else {
|
||||
regular.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort items within each group by date
|
||||
Object.keys(grouped).forEach((group) => {
|
||||
grouped[group].sort((a, b) => {
|
||||
const bookmarkA = bookmarks[a.id];
|
||||
const bookmarkB = bookmarks[b.id];
|
||||
const progressA = progressItems[a.id];
|
||||
const progressB = progressItems[b.id];
|
||||
|
||||
const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0);
|
||||
const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0);
|
||||
|
||||
return dateB - dateA;
|
||||
});
|
||||
});
|
||||
|
||||
return { groupedItems: grouped, regularItems: regular };
|
||||
}, [items, bookmarks, progressItems]);
|
||||
|
||||
// group sorting
|
||||
const allGroups = useMemo(() => {
|
||||
const groups = new Set<string>();
|
||||
|
||||
Object.values(bookmarks).forEach((bookmark) => {
|
||||
if (Array.isArray(bookmark.group)) {
|
||||
bookmark.group.forEach((group) => groups.add(group));
|
||||
}
|
||||
});
|
||||
|
||||
groups.add("bookmarks");
|
||||
|
||||
return Array.from(groups);
|
||||
}, [bookmarks]);
|
||||
|
||||
const sortableItems = useMemo(() => {
|
||||
const currentOrder = editOrderModal.isShown ? tempGroupOrder : groupOrder;
|
||||
|
||||
if (currentOrder.length === 0) {
|
||||
return allGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}
|
||||
|
||||
const orderMap = new Map(
|
||||
currentOrder.map((group, index) => [group, index]),
|
||||
);
|
||||
const sortedGroups = allGroups.sort((groupA, groupB) => {
|
||||
const orderA = orderMap.has(groupA)
|
||||
? orderMap.get(groupA)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const orderB = orderMap.has(groupB)
|
||||
? orderMap.get(groupB)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return sortedGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}, [allGroups, t, editOrderModal.isShown, tempGroupOrder, groupOrder]);
|
||||
|
||||
// Create a unified list of sections including both grouped and regular bookmarks
|
||||
const sortedSections = useMemo(() => {
|
||||
const sections: Array<{
|
||||
type: "grouped" | "regular";
|
||||
group?: string;
|
||||
items: MediaItem[];
|
||||
}> = [];
|
||||
|
||||
// Create a combined map of all sections (grouped + regular)
|
||||
const allSections = new Map<string, MediaItem[]>();
|
||||
|
||||
// Add grouped sections
|
||||
Object.entries(groupedItems).forEach(([group, groupItems]) => {
|
||||
allSections.set(group, groupItems);
|
||||
});
|
||||
|
||||
// Add regular bookmarks as "bookmarks" group
|
||||
if (regularItems.length > 0) {
|
||||
allSections.set("bookmarks", regularItems);
|
||||
}
|
||||
|
||||
// Sort sections based on group order
|
||||
if (groupOrder.length === 0) {
|
||||
// No order set, use default order
|
||||
allSections.forEach((sectionItems, group) => {
|
||||
if (group === "bookmarks") {
|
||||
sections.push({ type: "regular", items: sectionItems });
|
||||
} else {
|
||||
sections.push({ type: "grouped", group, items: sectionItems });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Use the saved order
|
||||
const orderMap = new Map(
|
||||
groupOrder.map((group, index) => [group, index]),
|
||||
);
|
||||
|
||||
Array.from(allSections.entries())
|
||||
.sort(([groupA], [groupB]) => {
|
||||
const orderA = orderMap.has(groupA)
|
||||
? orderMap.get(groupA)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const orderB = orderMap.has(groupB)
|
||||
? orderMap.get(groupB)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
})
|
||||
.forEach(([group, sectionItems]) => {
|
||||
if (group === "bookmarks") {
|
||||
sections.push({ type: "regular", items: sectionItems });
|
||||
} else {
|
||||
sections.push({ type: "grouped", group, items: sectionItems });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}, [groupedItems, regularItems, groupOrder]);
|
||||
// kill me
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (isScrolling) return;
|
||||
isScrolling = true;
|
||||
|
|
@ -112,8 +329,11 @@ export function BookmarksCarousel({
|
|||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault(); // Prevent default mouse action
|
||||
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
|
||||
// Only trigger long press for left mouse button (button 0)
|
||||
if (e.button === 0) {
|
||||
e.preventDefault(); // Prevent default mouse action
|
||||
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
|
|
@ -123,6 +343,82 @@ export function BookmarksCarousel({
|
|||
}
|
||||
};
|
||||
|
||||
const handleEditGroupOrder = () => {
|
||||
// Initialize with current order or default order
|
||||
if (groupOrder.length === 0) {
|
||||
const defaultOrder = allGroups.map((group) => group);
|
||||
setTempGroupOrder(defaultOrder);
|
||||
} else {
|
||||
setTempGroupOrder([...groupOrder]);
|
||||
}
|
||||
editOrderModal.show();
|
||||
};
|
||||
|
||||
const handleReorderClick = () => {
|
||||
handleEditGroupOrder();
|
||||
// Keep editing state active by setting it to true
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleCancelOrder = () => {
|
||||
editOrderModal.hide();
|
||||
};
|
||||
|
||||
const handleSaveOrderClick = () => {
|
||||
setGroupOrder(tempGroupOrder);
|
||||
editOrderModal.hide();
|
||||
|
||||
// Save to backend
|
||||
if (backendUrl && account) {
|
||||
useGroupOrderStore
|
||||
.getState()
|
||||
.saveGroupOrderToBackend(backendUrl, account);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to check overflow for a carousel
|
||||
const checkOverflow = (element: HTMLDivElement | null, key: string) => {
|
||||
if (!element) {
|
||||
setOverflowStates((prev) => ({ ...prev, [key]: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasOverflow = element.scrollWidth > element.clientWidth;
|
||||
setOverflowStates((prev) => ({ ...prev, [key]: hasOverflow }));
|
||||
};
|
||||
|
||||
// Function to set carousel ref and check overflow
|
||||
const setCarouselRef = (element: HTMLDivElement | null, key: string) => {
|
||||
// Set the ref for the main carousel refs
|
||||
carouselRefs.current[key] = element;
|
||||
|
||||
// Set the ref for overflow detection
|
||||
if (key === "bookmarks") {
|
||||
regularCarouselRef.current = element;
|
||||
} else {
|
||||
groupedCarouselRefs.current[key] = element;
|
||||
}
|
||||
|
||||
// Check overflow after a short delay to ensure content is rendered
|
||||
setTimeout(() => checkOverflow(element, key), 100);
|
||||
};
|
||||
|
||||
// Effect to recheck overflow on window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
// Recheck overflow for all carousels
|
||||
Object.keys(carouselRefs.current).forEach((key) => {
|
||||
const element = carouselRefs.current[key];
|
||||
if (element) {
|
||||
checkOverflow(element, key);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [carouselRefs]);
|
||||
|
||||
const categorySlug = "bookmarks";
|
||||
const SKELETON_COUNT = 10;
|
||||
|
||||
|
|
@ -130,69 +426,185 @@ export function BookmarksCarousel({
|
|||
|
||||
return (
|
||||
<>
|
||||
<SectionHeading
|
||||
title={t("home.bookmarks.sectionTitle") || "Bookmarks"}
|
||||
icon={Icons.BOOKMARK}
|
||||
className="ml-4 md:ml-12 mt-2 -mb-5"
|
||||
>
|
||||
<div className="mr-4 md:mr-8">
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
id="edit-button-bookmark"
|
||||
/>
|
||||
</div>
|
||||
</SectionHeading>
|
||||
<div className="relative overflow-hidden carousel-container md:pb-4">
|
||||
<div
|
||||
id={`carousel-${categorySlug}`}
|
||||
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
|
||||
ref={(el) => {
|
||||
carouselRefs.current[categorySlug] = el;
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div className="md:w-12" />
|
||||
|
||||
{items.length > 0
|
||||
? items.map((media) => (
|
||||
<div
|
||||
key={media.id}
|
||||
style={{ userSelect: "none" }}
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"
|
||||
>
|
||||
<WatchedMediaCard
|
||||
key={media.id}
|
||||
media={media}
|
||||
onShowDetails={onShowDetails}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(media.id)}
|
||||
{/* Grouped Bookmarks Carousels */}
|
||||
{sortedSections.map((section) => {
|
||||
if (section.type === "grouped") {
|
||||
const { icon, name } = parseGroupString(section.group || "");
|
||||
return (
|
||||
<div key={section.group}>
|
||||
<SectionHeading
|
||||
title={name}
|
||||
customIcon={
|
||||
<span className="w-6 h-6 flex items-center justify-center">
|
||||
<UserIcon icon={icon} className="w-full h-full" />
|
||||
</span>
|
||||
}
|
||||
className="ml-4 md:ml-12 mt-2 -mb-5"
|
||||
>
|
||||
<div className="mr-4 md:mr-8 flex items-center gap-2">
|
||||
{editing && allGroups.length > 1 && (
|
||||
<EditButtonWithText
|
||||
editing={editing}
|
||||
onEdit={handleReorderClick}
|
||||
id="edit-group-order-button-carousel"
|
||||
text={t("home.bookmarks.groups.reorder.button")}
|
||||
secondaryText={t("home.bookmarks.groups.reorder.done")}
|
||||
/>
|
||||
)}
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
id={`edit-button-bookmark-${section.group}`}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: Array.from({ length: SKELETON_COUNT }).map(() => (
|
||||
<MediaCardSkeleton
|
||||
key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`}
|
||||
</SectionHeading>
|
||||
<div className="relative overflow-hidden carousel-container md:pb-4">
|
||||
<div
|
||||
id={`carousel-${section.group}`}
|
||||
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
|
||||
ref={(el) => setCarouselRef(el, section.group || "bookmarks")}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div className="md:w-12" />
|
||||
|
||||
{section.items
|
||||
.slice(0, MAX_ITEMS_PER_SECTION)
|
||||
.map((media) => (
|
||||
<div
|
||||
key={media.id}
|
||||
style={{ userSelect: "none" }}
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"
|
||||
>
|
||||
<WatchedMediaCard
|
||||
key={media.id}
|
||||
media={media}
|
||||
onShowDetails={onShowDetails}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(media.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{section.items.length > MAX_ITEMS_PER_SECTION && (
|
||||
<MoreBookmarksCard />
|
||||
)}
|
||||
|
||||
<div className="md:w-12" />
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<CarouselNavButtons
|
||||
categorySlug={section.group || "bookmarks"}
|
||||
carouselRefs={carouselRefs}
|
||||
hasOverflow={overflowStates[section.group || "bookmarks"]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} // regular items
|
||||
return (
|
||||
<div key="regular-bookmarks">
|
||||
<SectionHeading
|
||||
title={t("home.bookmarks.sectionTitle")}
|
||||
icon={Icons.BOOKMARK}
|
||||
className="ml-4 md:ml-12 mt-2 -mb-5"
|
||||
>
|
||||
<div className="mr-4 md:mr-8 flex items-center gap-2">
|
||||
{editing && allGroups.length > 1 && (
|
||||
<EditButtonWithText
|
||||
editing={editing}
|
||||
onEdit={handleReorderClick}
|
||||
id="edit-group-order-button-carousel"
|
||||
text={t("home.bookmarks.groups.reorder.button")}
|
||||
secondaryText={t("home.bookmarks.groups.reorder.done")}
|
||||
/>
|
||||
)}
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
id="edit-button-bookmark"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SectionHeading>
|
||||
<div className="relative overflow-hidden carousel-container md:pb-4">
|
||||
<div
|
||||
id={`carousel-${categorySlug}`}
|
||||
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
|
||||
ref={(el) => setCarouselRef(el, categorySlug)}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div className="md:w-12" />
|
||||
|
||||
<div className="md:w-12" />
|
||||
</div>
|
||||
{section.items.length > 0
|
||||
? section.items
|
||||
.slice(0, MAX_ITEMS_PER_SECTION)
|
||||
.map((media) => (
|
||||
<div
|
||||
key={media.id}
|
||||
style={{ userSelect: "none" }}
|
||||
onContextMenu={(
|
||||
e: React.MouseEvent<HTMLDivElement>,
|
||||
) => e.preventDefault()}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"
|
||||
>
|
||||
<WatchedMediaCard
|
||||
key={media.id}
|
||||
media={media}
|
||||
onShowDetails={onShowDetails}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(media.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: Array.from({ length: SKELETON_COUNT }).map(() => (
|
||||
<MediaCardSkeleton
|
||||
key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isMobile && (
|
||||
<CarouselNavButtons
|
||||
categorySlug={categorySlug}
|
||||
carouselRefs={carouselRefs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{section.items.length > MAX_ITEMS_PER_SECTION && (
|
||||
<MoreBookmarksCard />
|
||||
)}
|
||||
|
||||
<div className="md:w-12" />
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<CarouselNavButtons
|
||||
categorySlug={categorySlug}
|
||||
carouselRefs={carouselRefs}
|
||||
hasOverflow={overflowStates[categorySlug]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Edit Order Modal */}
|
||||
<EditGroupOrderModal
|
||||
id={editOrderModal.id}
|
||||
isShown={editOrderModal.isShown}
|
||||
items={sortableItems}
|
||||
onCancel={handleCancelOrder}
|
||||
onSave={handleSaveOrderClick}
|
||||
onItemsChange={(newItems) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
setTempGroupOrder(newOrder);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,33 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { EditButton } from "@/components/buttons/EditButton";
|
||||
import { EditButtonWithText } from "@/components/buttons/EditButtonWithText";
|
||||
import { Item } from "@/components/form/SortableList";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { MediaGrid } from "@/components/media/MediaGrid";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { UserIcon, UserIcons } from "@/components/UserIcon";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { useGroupOrderStore } from "@/stores/groupOrder";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
function parseGroupString(group: string): { icon: UserIcons; name: string } {
|
||||
const match = group.match(/^\[([a-zA-Z0-9_]+)\](.*)$/);
|
||||
if (match) {
|
||||
const iconKey = match[1].toUpperCase() as keyof typeof UserIcons;
|
||||
const icon = UserIcons[iconKey] || UserIcons.BOOKMARK;
|
||||
const name = match[2].trim();
|
||||
return { icon, name };
|
||||
}
|
||||
return { icon: UserIcons.BOOKMARK, name: group };
|
||||
}
|
||||
|
||||
const LONG_PRESS_DURATION = 700; // 0.7 seconds
|
||||
|
||||
export function BookmarksPart({
|
||||
|
|
@ -23,9 +42,15 @@ export function BookmarksPart({
|
|||
const { t } = useTranslation();
|
||||
const progressItems = useProgressStore((s) => s.items);
|
||||
const bookmarks = useBookmarkStore((s) => s.bookmarks);
|
||||
const groupOrder = useGroupOrderStore((s) => s.groupOrder);
|
||||
const setGroupOrder = useGroupOrderStore((s) => s.setGroupOrder);
|
||||
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>();
|
||||
const editOrderModal = useModal("bookmark-edit-order");
|
||||
const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]);
|
||||
const backendUrl = useBackendUrl();
|
||||
const account = useAuthStore((s) => s.account);
|
||||
|
||||
const pressTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
|
|
@ -51,6 +76,145 @@ export function BookmarksPart({
|
|||
return output;
|
||||
}, [bookmarks, progressItems]);
|
||||
|
||||
const { groupedItems, regularItems } = useMemo(() => {
|
||||
const grouped: Record<string, MediaItem[]> = {};
|
||||
const regular: MediaItem[] = [];
|
||||
|
||||
items.forEach((item) => {
|
||||
const bookmark = bookmarks[item.id];
|
||||
if (Array.isArray(bookmark?.group)) {
|
||||
bookmark.group.forEach((groupName) => {
|
||||
if (!grouped[groupName]) {
|
||||
grouped[groupName] = [];
|
||||
}
|
||||
grouped[groupName].push(item);
|
||||
});
|
||||
} else {
|
||||
regular.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort items within each group by date
|
||||
Object.keys(grouped).forEach((group) => {
|
||||
grouped[group].sort((a, b) => {
|
||||
const bookmarkA = bookmarks[a.id];
|
||||
const bookmarkB = bookmarks[b.id];
|
||||
const progressA = progressItems[a.id];
|
||||
const progressB = progressItems[b.id];
|
||||
|
||||
const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0);
|
||||
const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0);
|
||||
|
||||
return dateB - dateA;
|
||||
});
|
||||
});
|
||||
|
||||
return { groupedItems: grouped, regularItems: regular };
|
||||
}, [items, bookmarks, progressItems]);
|
||||
|
||||
// group sorting
|
||||
const allGroups = useMemo(() => {
|
||||
const groups = new Set<string>();
|
||||
|
||||
Object.values(bookmarks).forEach((bookmark) => {
|
||||
if (Array.isArray(bookmark.group)) {
|
||||
bookmark.group.forEach((group) => groups.add(group));
|
||||
}
|
||||
});
|
||||
|
||||
groups.add("bookmarks");
|
||||
|
||||
return Array.from(groups);
|
||||
}, [bookmarks]);
|
||||
|
||||
const sortableItems = useMemo(() => {
|
||||
const currentOrder = editOrderModal.isShown ? tempGroupOrder : groupOrder;
|
||||
|
||||
if (currentOrder.length === 0) {
|
||||
return allGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}
|
||||
|
||||
const orderMap = new Map(
|
||||
currentOrder.map((group, index) => [group, index]),
|
||||
);
|
||||
const sortedGroups = allGroups.sort((groupA, groupB) => {
|
||||
const orderA = orderMap.has(groupA)
|
||||
? orderMap.get(groupA)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const orderB = orderMap.has(groupB)
|
||||
? orderMap.get(groupB)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return sortedGroups.map((group) => {
|
||||
const { name } = parseGroupString(group);
|
||||
return {
|
||||
id: group,
|
||||
name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name,
|
||||
} as Item;
|
||||
});
|
||||
}, [allGroups, t, editOrderModal.isShown, tempGroupOrder, groupOrder]);
|
||||
|
||||
const sortedSections = useMemo(() => {
|
||||
const sections: Array<{
|
||||
type: "grouped" | "regular";
|
||||
group?: string;
|
||||
items: MediaItem[];
|
||||
}> = [];
|
||||
|
||||
const allSections = new Map<string, MediaItem[]>();
|
||||
|
||||
Object.entries(groupedItems).forEach(([group, groupItems]) => {
|
||||
allSections.set(group, groupItems);
|
||||
});
|
||||
|
||||
if (regularItems.length > 0) {
|
||||
allSections.set("bookmarks", regularItems);
|
||||
}
|
||||
|
||||
if (groupOrder.length === 0) {
|
||||
allSections.forEach((sectionItems, group) => {
|
||||
if (group === "bookmarks") {
|
||||
sections.push({ type: "regular", items: sectionItems });
|
||||
} else {
|
||||
sections.push({ type: "grouped", group, items: sectionItems });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const orderMap = new Map(
|
||||
groupOrder.map((group, index) => [group, index]),
|
||||
);
|
||||
|
||||
Array.from(allSections.entries())
|
||||
.sort(([groupA], [groupB]) => {
|
||||
const orderA = orderMap.has(groupA)
|
||||
? orderMap.get(groupA)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const orderB = orderMap.has(groupB)
|
||||
? orderMap.get(groupB)!
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
})
|
||||
.forEach(([group, sectionItems]) => {
|
||||
if (group === "bookmarks") {
|
||||
sections.push({ type: "regular", items: sectionItems });
|
||||
} else {
|
||||
sections.push({ type: "grouped", group, items: sectionItems });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}, [groupedItems, regularItems, groupOrder]);
|
||||
// kill me
|
||||
|
||||
useEffect(() => {
|
||||
onItemsChange(items.length > 0);
|
||||
}, [items, onItemsChange]);
|
||||
|
|
@ -87,42 +251,160 @@ export function BookmarksPart({
|
|||
}
|
||||
};
|
||||
|
||||
const handleEditGroupOrder = () => {
|
||||
// Initialize with current order or default order
|
||||
if (groupOrder.length === 0) {
|
||||
const defaultOrder = allGroups.map((group) => group);
|
||||
setTempGroupOrder(defaultOrder);
|
||||
} else {
|
||||
setTempGroupOrder([...groupOrder]);
|
||||
}
|
||||
editOrderModal.show();
|
||||
};
|
||||
|
||||
const handleReorderClick = () => {
|
||||
handleEditGroupOrder();
|
||||
// Keep editing state active by setting it to true
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleCancelOrder = () => {
|
||||
editOrderModal.hide();
|
||||
};
|
||||
|
||||
const handleSaveOrderClick = () => {
|
||||
setGroupOrder(tempGroupOrder);
|
||||
editOrderModal.hide();
|
||||
|
||||
// Save to backend
|
||||
if (backendUrl && account) {
|
||||
useGroupOrderStore
|
||||
.getState()
|
||||
.saveGroupOrderToBackend(backendUrl, account);
|
||||
}
|
||||
};
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<SectionHeading
|
||||
title={t("home.bookmarks.sectionTitle") || "Bookmarks"}
|
||||
icon={Icons.BOOKMARK}
|
||||
>
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
id="edit-button-bookmark"
|
||||
/>
|
||||
</SectionHeading>
|
||||
<MediaGrid ref={gridRef}>
|
||||
{items.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
style={{ userSelect: "none" }}
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<WatchedMediaCard
|
||||
media={v}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(v.id)}
|
||||
onShowDetails={onShowDetails}
|
||||
/>
|
||||
{/* Grouped Bookmarks */}
|
||||
{sortedSections.map((section) => {
|
||||
if (section.type === "grouped") {
|
||||
const { icon, name } = parseGroupString(section.group || "");
|
||||
return (
|
||||
<div key={section.group || "bookmarks"} className="mb-6">
|
||||
<SectionHeading
|
||||
title={name}
|
||||
customIcon={
|
||||
<span className="w-6 h-6 flex items-center justify-center">
|
||||
<UserIcon icon={icon} className="w-full h-full" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing && allGroups.length > 1 && (
|
||||
<EditButtonWithText
|
||||
editing={editing}
|
||||
onEdit={handleReorderClick}
|
||||
id="edit-group-order-button"
|
||||
text={t("home.bookmarks.groups.reorder.button")}
|
||||
secondaryText={t("home.bookmarks.groups.reorder.done")}
|
||||
/>
|
||||
)}
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
id={`edit-button-bookmark-${section.group}`}
|
||||
/>
|
||||
</div>
|
||||
</SectionHeading>
|
||||
<MediaGrid>
|
||||
{section.items.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
style={{ userSelect: "none" }}
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<WatchedMediaCard
|
||||
media={v}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(v.id)}
|
||||
onShowDetails={onShowDetails}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</MediaGrid>
|
||||
</div>
|
||||
);
|
||||
} // regular items
|
||||
return (
|
||||
<div key="regular-bookmarks" className="mb-6">
|
||||
<SectionHeading
|
||||
title={t("home.bookmarks.sectionTitle")}
|
||||
icon={Icons.BOOKMARK}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{editing && allGroups.length > 1 && (
|
||||
<EditButtonWithText
|
||||
editing={editing}
|
||||
onEdit={handleReorderClick}
|
||||
id="edit-group-order-button"
|
||||
text={t("home.bookmarks.groups.reorder.button")}
|
||||
secondaryText={t("home.bookmarks.groups.reorder.done")}
|
||||
/>
|
||||
)}
|
||||
<EditButton
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
id="edit-button-bookmark"
|
||||
/>
|
||||
</div>
|
||||
</SectionHeading>
|
||||
<MediaGrid ref={gridRef}>
|
||||
{section.items.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
style={{ userSelect: "none" }}
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<WatchedMediaCard
|
||||
media={v}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(v.id)}
|
||||
onShowDetails={onShowDetails}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</MediaGrid>
|
||||
</div>
|
||||
))}
|
||||
</MediaGrid>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Edit Order Modal */}
|
||||
<EditGroupOrderModal
|
||||
id={editOrderModal.id}
|
||||
isShown={editOrderModal.isShown}
|
||||
items={sortableItems}
|
||||
onCancel={handleCancelOrder}
|
||||
onSave={handleSaveOrderClick}
|
||||
onItemsChange={(newItems) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
setTempGroupOrder(newOrder);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { EditButton } from "@/components/buttons/EditButton";
|
||||
|
|
@ -42,6 +42,9 @@ export function WatchingCarousel({
|
|||
const removeItem = useProgressStore((s) => s.removeItem);
|
||||
const pressTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Track overflow state
|
||||
const [hasOverflow, setHasOverflow] = useState(false);
|
||||
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
const itemsLength = useProgressStore((state) => {
|
||||
|
|
@ -108,8 +111,11 @@ export function WatchingCarousel({
|
|||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault(); // Prevent default mouse action
|
||||
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
|
||||
// Only trigger long press for left mouse button (button 0)
|
||||
if (e.button === 0) {
|
||||
e.preventDefault(); // Prevent default mouse action
|
||||
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
|
|
@ -119,6 +125,38 @@ export function WatchingCarousel({
|
|||
}
|
||||
};
|
||||
|
||||
// Function to check overflow for the carousel
|
||||
const checkOverflow = (element: HTMLDivElement | null) => {
|
||||
if (!element) {
|
||||
setHasOverflow(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasHorizontalOverflow = element.scrollWidth > element.clientWidth;
|
||||
setHasOverflow(hasHorizontalOverflow);
|
||||
};
|
||||
|
||||
// Function to set carousel ref and check overflow
|
||||
const setCarouselRef = (element: HTMLDivElement | null) => {
|
||||
carouselRefs.current[categorySlug] = element;
|
||||
|
||||
// Check overflow after a short delay to ensure content is rendered
|
||||
setTimeout(() => checkOverflow(element), 100);
|
||||
};
|
||||
|
||||
// Effect to recheck overflow on window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const element = carouselRefs.current[categorySlug];
|
||||
if (element) {
|
||||
checkOverflow(element);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [carouselRefs, categorySlug]);
|
||||
|
||||
if (itemsLength === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -140,9 +178,7 @@ export function WatchingCarousel({
|
|||
<div
|
||||
id={`carousel-${categorySlug}`}
|
||||
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
|
||||
ref={(el) => {
|
||||
carouselRefs.current[categorySlug] = el;
|
||||
}}
|
||||
ref={setCarouselRef}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div className="md:w-12" />
|
||||
|
|
@ -183,6 +219,7 @@ export function WatchingCarousel({
|
|||
<CarouselNavButtons
|
||||
categorySlug={categorySlug}
|
||||
carouselRefs={carouselRefs}
|
||||
hasOverflow={hasOverflow}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -68,8 +68,11 @@ export function WatchingPart({
|
|||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault(); // Prevent default mouse action
|
||||
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
|
||||
// Only trigger long press for left mouse button (button 0)
|
||||
if (e.button === 0) {
|
||||
e.preventDefault(); // Prevent default mouse action
|
||||
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
|
|
|
|||
|
|
@ -29,12 +29,35 @@ export function PlaybackErrorPart() {
|
|||
}
|
||||
}, [playbackError, settingsRouter]);
|
||||
|
||||
const handleOpenSourcePicker = () => {
|
||||
settingsRouter.open();
|
||||
settingsRouter.navigate("/source");
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorLayout>
|
||||
<ErrorContainer>
|
||||
<IconPill icon={Icons.WAND}>{t("player.playbackError.badge")}</IconPill>
|
||||
<Title>{t("player.playbackError.title")}</Title>
|
||||
<Paragraph>{t("player.playbackError.text")}</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={() => modal.show()}
|
||||
theme="danger"
|
||||
padding="md:px-12 p-2.5"
|
||||
className="mt-6"
|
||||
>
|
||||
{t("errors.showError")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleOpenSourcePicker}
|
||||
theme="purple"
|
||||
padding="md:px-12 p-2.5"
|
||||
className="mt-6"
|
||||
>
|
||||
{t("player.menus.sources.title")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
href="/"
|
||||
|
|
@ -45,25 +68,17 @@ export function PlaybackErrorPart() {
|
|||
{t("player.playbackError.homeButton")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => modal.show()}
|
||||
theme="purple"
|
||||
theme="secondary"
|
||||
padding="md:px-12 p-2.5"
|
||||
className="mt-6"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
{t("errors.showError")}
|
||||
{t("errors.reloadPage")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
theme="secondary"
|
||||
padding="md:px-12 p-2.5"
|
||||
className="mt-6"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
{t("errors.reloadPage")}
|
||||
</Button>
|
||||
</ErrorContainer>
|
||||
{/* Error */}
|
||||
<ErrorCardInModal
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ export function PlayerPart(props: PlayerPartProps) {
|
|||
|
||||
<Player.VolumeChangedPopout />
|
||||
<Player.SubtitleDelayPopout />
|
||||
<Player.SpeedChangedPopout />
|
||||
<UnreleasedEpisodeOverlay />
|
||||
|
||||
<Player.NextEpisodeButton
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ import {
|
|||
|
||||
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
|
||||
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
|
||||
import { NotificationModal } from "@/components/overlays/notificationsModal";
|
||||
import { useOnlineListener } from "@/hooks/usePing";
|
||||
import { AboutPage } from "@/pages/About";
|
||||
import { AdminPage } from "@/pages/admin/AdminPage";
|
||||
import { AllBookmarks } from "@/pages/bookmarks/AllBookmarks";
|
||||
import VideoTesterView from "@/pages/developer/VideoTesterView";
|
||||
import { DiscoverMore } from "@/pages/discover/AllMovieLists";
|
||||
import { Discover } from "@/pages/discover/Discover";
|
||||
|
|
@ -116,6 +118,7 @@ function App() {
|
|||
return (
|
||||
<Layout>
|
||||
<LanguageProvider />
|
||||
<NotificationModal id="notifications" />
|
||||
{!showDowntime && (
|
||||
<Routes>
|
||||
{/* functional routes */}
|
||||
|
|
@ -182,6 +185,8 @@ function App() {
|
|||
/>
|
||||
<Route path="/discover/more/:category" element={<MoreContent />} />
|
||||
<Route path="/discover/all" element={<DiscoverMore />} />
|
||||
{/* Bookmarks page */}
|
||||
<Route path="/bookmarks" element={<AllBookmarks />} />
|
||||
{/* Settings page */}
|
||||
<Route
|
||||
path="/settings"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ async function syncBookmarks(
|
|||
year: item.year ?? NaN,
|
||||
},
|
||||
tmdbId: item.tmdbId,
|
||||
group: item.group,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface BookmarkMediaItem {
|
|||
poster?: string;
|
||||
type: "show" | "movie";
|
||||
updatedAt: number;
|
||||
group?: string[];
|
||||
}
|
||||
|
||||
export interface BookmarkUpdateItem {
|
||||
|
|
@ -19,6 +20,7 @@ export interface BookmarkUpdateItem {
|
|||
id: string;
|
||||
poster?: string;
|
||||
type?: "show" | "movie";
|
||||
group?: string[];
|
||||
action: "delete" | "add";
|
||||
}
|
||||
|
||||
|
|
@ -26,6 +28,7 @@ export interface BookmarkStore {
|
|||
bookmarks: Record<string, BookmarkMediaItem>;
|
||||
updateQueue: BookmarkUpdateItem[];
|
||||
addBookmark(meta: PlayerMeta): void;
|
||||
addBookmarkWithGroups(meta: PlayerMeta, groups?: string[]): void;
|
||||
removeBookmark(id: string): void;
|
||||
replaceBookmarks(items: Record<string, BookmarkMediaItem>): void;
|
||||
clear(): void;
|
||||
|
|
@ -74,6 +77,30 @@ export const useBookmarkStore = create(
|
|||
};
|
||||
});
|
||||
},
|
||||
addBookmarkWithGroups(meta, groups) {
|
||||
set((s) => {
|
||||
updateId += 1;
|
||||
s.updateQueue.push({
|
||||
id: updateId.toString(),
|
||||
action: "add",
|
||||
tmdbId: meta.tmdbId,
|
||||
type: meta.type,
|
||||
title: meta.title,
|
||||
year: meta.releaseYear,
|
||||
poster: meta.poster,
|
||||
group: groups,
|
||||
});
|
||||
|
||||
s.bookmarks[meta.tmdbId] = {
|
||||
type: meta.type,
|
||||
title: meta.title,
|
||||
year: meta.releaseYear,
|
||||
poster: meta.poster,
|
||||
updatedAt: Date.now(),
|
||||
group: groups,
|
||||
};
|
||||
});
|
||||
},
|
||||
replaceBookmarks(items: Record<string, BookmarkMediaItem>) {
|
||||
set((s) => {
|
||||
s.bookmarks = items;
|
||||
|
|
|
|||
55
src/stores/groupOrder/GroupSyncer.tsx
Normal file
55
src/stores/groupOrder/GroupSyncer.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { updateGroupOrder } from "@/backend/accounts/groupOrder";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useGroupOrderStore } from "@/stores/groupOrder";
|
||||
|
||||
const syncIntervalMs = 5 * 1000;
|
||||
|
||||
export function GroupSyncer() {
|
||||
const url = useBackendUrl();
|
||||
const groupOrder = useGroupOrderStore((s) => s.groupOrder);
|
||||
const lastSyncedOrder = useRef<string[]>([]);
|
||||
const isInitialized = useRef(false);
|
||||
|
||||
// Initialize lastSyncedOrder on first render
|
||||
useEffect(() => {
|
||||
if (!isInitialized.current) {
|
||||
lastSyncedOrder.current = [...groupOrder];
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [groupOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
(async () => {
|
||||
if (!url) return;
|
||||
|
||||
const user = useAuthStore.getState();
|
||||
if (!user.account) return; // not logged in, dont sync to server
|
||||
|
||||
// Check if group order has changed since last sync
|
||||
const currentOrder = useGroupOrderStore.getState().groupOrder;
|
||||
const hasChanged =
|
||||
JSON.stringify(currentOrder) !==
|
||||
JSON.stringify(lastSyncedOrder.current);
|
||||
|
||||
if (hasChanged) {
|
||||
try {
|
||||
await updateGroupOrder(url, user.account, currentOrder);
|
||||
lastSyncedOrder.current = [...currentOrder];
|
||||
} catch (err) {
|
||||
console.error("Failed to sync group order:", err);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, syncIntervalMs);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return null;
|
||||
}
|
||||
65
src/stores/groupOrder/index.ts
Normal file
65
src/stores/groupOrder/index.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
|
||||
import { getGroupOrder, updateGroupOrder } from "@/backend/accounts/groupOrder";
|
||||
import { AccountWithToken } from "@/stores/auth";
|
||||
|
||||
export interface GroupOrderStore {
|
||||
groupOrder: string[];
|
||||
setGroupOrder(order: string[]): void;
|
||||
saveGroupOrderToBackend(
|
||||
backendUrl: string,
|
||||
account: AccountWithToken,
|
||||
): Promise<void>;
|
||||
loadGroupOrderFromBackend(
|
||||
backendUrl: string,
|
||||
account: AccountWithToken,
|
||||
): Promise<void>;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export const useGroupOrderStore = create(
|
||||
persist(
|
||||
immer<GroupOrderStore>((set) => ({
|
||||
groupOrder: [],
|
||||
setGroupOrder(order: string[]) {
|
||||
set((s) => {
|
||||
s.groupOrder = order;
|
||||
});
|
||||
},
|
||||
async saveGroupOrderToBackend(
|
||||
backendUrl: string,
|
||||
account: AccountWithToken,
|
||||
) {
|
||||
if (!account || !backendUrl) {
|
||||
throw new Error("No authenticated account or backend URL");
|
||||
}
|
||||
|
||||
const currentState = useGroupOrderStore.getState();
|
||||
await updateGroupOrder(backendUrl, account, currentState.groupOrder);
|
||||
},
|
||||
async loadGroupOrderFromBackend(
|
||||
backendUrl: string,
|
||||
account: AccountWithToken,
|
||||
) {
|
||||
if (!account || !backendUrl) {
|
||||
throw new Error("No authenticated account or backend URL");
|
||||
}
|
||||
|
||||
const response = await getGroupOrder(backendUrl, account);
|
||||
set((s) => {
|
||||
s.groupOrder = response.groupOrder;
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
set((s) => {
|
||||
s.groupOrder = [];
|
||||
});
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: "__MW::groupOrder",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -1,13 +1,42 @@
|
|||
import { create } from "zustand";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
|
||||
type OverlayType = "volume" | "subtitle" | null;
|
||||
type OverlayType = "volume" | "subtitle" | "speed" | null;
|
||||
|
||||
interface OverlayStackStore {
|
||||
currentOverlay: OverlayType;
|
||||
modalStack: string[];
|
||||
setCurrentOverlay: (overlay: OverlayType) => void;
|
||||
showModal: (id: string) => void;
|
||||
hideModal: (id: string) => void;
|
||||
isModalVisible: (id: string) => boolean;
|
||||
getTopModal: () => string | null;
|
||||
}
|
||||
|
||||
export const useOverlayStack = create<OverlayStackStore>((set) => ({
|
||||
currentOverlay: null,
|
||||
setCurrentOverlay: (overlay) => set({ currentOverlay: overlay }),
|
||||
}));
|
||||
export const useOverlayStack = create<OverlayStackStore>()(
|
||||
immer((set, get) => ({
|
||||
currentOverlay: null,
|
||||
modalStack: [],
|
||||
setCurrentOverlay: (overlay) =>
|
||||
set((state) => {
|
||||
state.currentOverlay = overlay;
|
||||
}),
|
||||
showModal: (id: string) =>
|
||||
set((state) => {
|
||||
if (!state.modalStack.includes(id)) {
|
||||
state.modalStack.push(id);
|
||||
}
|
||||
}),
|
||||
hideModal: (id: string) =>
|
||||
set((state) => {
|
||||
state.modalStack = state.modalStack.filter((modalId) => modalId !== id);
|
||||
}),
|
||||
isModalVisible: (id: string) => {
|
||||
return get().modalStack.includes(id);
|
||||
},
|
||||
getTopModal: () => {
|
||||
const stack = get().modalStack;
|
||||
return stack.length > 0 ? stack[stack.length - 1] : null;
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export interface InterfaceSlice {
|
|||
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
|
||||
isHoveringControls: boolean; // is the cursor hovered over any controls?
|
||||
timeFormat: VideoPlayerTimeFormat; // Time format of the video player
|
||||
isSpeedBoosted: boolean; // is playback speed temporarily boosted to 2x
|
||||
showSpeedIndicator: boolean; // should the speed indicator be shown
|
||||
};
|
||||
updateInterfaceHovering(newState: PlayerHoverState): void;
|
||||
setSeeking(seeking: boolean): void;
|
||||
|
|
@ -42,6 +44,8 @@ export interface InterfaceSlice {
|
|||
setLastVolume(state: number): void;
|
||||
hideNextEpisodeButton(): void;
|
||||
setShouldStartFromBeginning(val: boolean): void;
|
||||
setSpeedBoosted(state: boolean): void;
|
||||
setShowSpeedIndicator(state: boolean): void;
|
||||
}
|
||||
|
||||
export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
|
||||
|
|
@ -61,6 +65,8 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
|
|||
canAirplay: false,
|
||||
hideNextEpisodeBtn: false,
|
||||
shouldStartFromBeginning: false,
|
||||
isSpeedBoosted: false,
|
||||
showSpeedIndicator: false,
|
||||
},
|
||||
|
||||
setShouldStartFromBeginning(val) {
|
||||
|
|
@ -112,4 +118,14 @@ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
|
|||
s.interface.hideNextEpisodeBtn = true;
|
||||
});
|
||||
},
|
||||
setSpeedBoosted(state) {
|
||||
set((s) => {
|
||||
s.interface.isSpeedBoosted = state;
|
||||
});
|
||||
},
|
||||
setShowSpeedIndicator(state) {
|
||||
set((s) => {
|
||||
s.interface.showSpeedIndicator = state;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable no-console */
|
||||
import { ScrapeMedia } from "@p-stream/providers";
|
||||
|
||||
import { MakeSlice } from "@/stores/player/slices/types";
|
||||
|
|
@ -98,6 +99,7 @@ export interface SourceSlice {
|
|||
enableAutomaticQuality(): void;
|
||||
redisplaySource(startAt: number): void;
|
||||
setCaptionAsTrack(asTrack: boolean): void;
|
||||
addExternalSubtitles(): Promise<void>;
|
||||
}
|
||||
|
||||
export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
|
||||
|
|
@ -184,6 +186,12 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
|||
});
|
||||
const store = get();
|
||||
store.redisplaySource(startAt);
|
||||
|
||||
// Trigger external subtitle scraping after stream is loaded
|
||||
// This runs asynchronously so it doesn't block the stream loading
|
||||
setTimeout(() => {
|
||||
store.addExternalSubtitles();
|
||||
}, 100);
|
||||
},
|
||||
redisplaySource(startAt: number) {
|
||||
const store = get();
|
||||
|
|
@ -235,4 +243,29 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
|||
s.caption.asTrack = asTrack;
|
||||
});
|
||||
},
|
||||
async addExternalSubtitles() {
|
||||
const store = get();
|
||||
if (!store.meta) return;
|
||||
|
||||
try {
|
||||
const { scrapeExternalSubtitles } = await import(
|
||||
"@/utils/externalSubtitles"
|
||||
);
|
||||
const externalCaptions = await scrapeExternalSubtitles(store.meta);
|
||||
|
||||
if (externalCaptions.length > 0) {
|
||||
set((s) => {
|
||||
// Add external captions to the existing list, avoiding duplicates
|
||||
const existingIds = new Set(s.captionList.map((c) => c.id));
|
||||
const newCaptions = externalCaptions.filter(
|
||||
(c) => !existingIds.has(c.id),
|
||||
);
|
||||
s.captionList = [...s.captionList, ...newCaptions];
|
||||
});
|
||||
console.log(`Added ${externalCaptions.length} external captions`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to scrape external subtitles:", error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
195
src/utils/errorDebugInfo.ts
Normal file
195
src/utils/errorDebugInfo.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { detect } from "detect-browser";
|
||||
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
|
||||
export interface ErrorDebugInfo {
|
||||
timestamp: string;
|
||||
error: {
|
||||
message: string;
|
||||
type: string;
|
||||
stackTrace?: string;
|
||||
};
|
||||
device: {
|
||||
userAgent: string;
|
||||
browser: string;
|
||||
os: string;
|
||||
isMobile: boolean;
|
||||
isTV: boolean;
|
||||
screenResolution: string;
|
||||
viewportSize: string;
|
||||
};
|
||||
player: {
|
||||
status: string;
|
||||
sourceId: string | null;
|
||||
currentQuality: string | null;
|
||||
meta: {
|
||||
title: string;
|
||||
type: string;
|
||||
tmdbId: string;
|
||||
imdbId?: string;
|
||||
releaseYear: number;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
} | null;
|
||||
};
|
||||
network: {
|
||||
online: boolean;
|
||||
connectionType?: string;
|
||||
effectiveType?: string;
|
||||
downlink?: number;
|
||||
rtt?: number;
|
||||
};
|
||||
|
||||
performance: {
|
||||
memory?: {
|
||||
usedJSHeapSize: number;
|
||||
totalJSHeapSize: number;
|
||||
jsHeapSizeLimit: number;
|
||||
};
|
||||
timing: {
|
||||
navigationStart: number;
|
||||
loadEventEnd: number;
|
||||
domContentLoadedEventEnd: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function gatherErrorDebugInfo(error: any): ErrorDebugInfo {
|
||||
const browserInfo = detect();
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const isTV =
|
||||
/SmartTV|Tizen|WebOS|SamsungBrowser|HbbTV|Viera|NetCast|AppleTV|Android TV|GoogleTV|Roku|PlayStation|Xbox|Opera TV|AquosBrowser|Hisense|SonyBrowser|SharpBrowser|AFT|Chromecast/i.test(
|
||||
navigator.userAgent,
|
||||
);
|
||||
|
||||
const playerStore = usePlayerStore.getState();
|
||||
|
||||
// Get network information
|
||||
const connection =
|
||||
(navigator as any).connection ||
|
||||
(navigator as any).mozConnection ||
|
||||
(navigator as any).webkitConnection;
|
||||
|
||||
// Get performance information
|
||||
const performanceInfo = performance.getEntriesByType(
|
||||
"navigation",
|
||||
)[0] as PerformanceNavigationTiming;
|
||||
const memory = (performance as any).memory;
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
error: {
|
||||
message: error?.message || error?.key || String(error),
|
||||
type: error?.type || "unknown",
|
||||
stackTrace: error?.stackTrace || error?.stack,
|
||||
},
|
||||
device: {
|
||||
userAgent: navigator.userAgent,
|
||||
browser: browserInfo?.name || "unknown",
|
||||
os: browserInfo?.os || "unknown",
|
||||
isMobile,
|
||||
isTV,
|
||||
screenResolution: `${window.screen.width}x${window.screen.height}`,
|
||||
viewportSize: `${window.innerWidth}x${window.innerHeight}`,
|
||||
},
|
||||
player: {
|
||||
status: playerStore.status,
|
||||
sourceId: playerStore.sourceId,
|
||||
currentQuality: playerStore.currentQuality,
|
||||
meta: playerStore.meta
|
||||
? {
|
||||
title: playerStore.meta.title,
|
||||
type: playerStore.meta.type,
|
||||
tmdbId: playerStore.meta.tmdbId,
|
||||
imdbId: playerStore.meta.imdbId,
|
||||
releaseYear: playerStore.meta.releaseYear,
|
||||
season: playerStore.meta.season?.number,
|
||||
episode: playerStore.meta.episode?.number,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
network: {
|
||||
online: navigator.onLine,
|
||||
connectionType: connection?.type,
|
||||
effectiveType: connection?.effectiveType,
|
||||
downlink: connection?.downlink,
|
||||
rtt: connection?.rtt,
|
||||
},
|
||||
performance: {
|
||||
memory: memory
|
||||
? {
|
||||
usedJSHeapSize: memory.usedJSHeapSize,
|
||||
totalJSHeapSize: memory.totalJSHeapSize,
|
||||
jsHeapSizeLimit: memory.jsHeapSizeLimit,
|
||||
}
|
||||
: undefined,
|
||||
timing: {
|
||||
navigationStart: performanceInfo?.fetchStart || 0,
|
||||
loadEventEnd: performanceInfo?.loadEventEnd || 0,
|
||||
domContentLoadedEventEnd:
|
||||
performanceInfo?.domContentLoadedEventEnd || 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function formatErrorDebugInfo(info: ErrorDebugInfo): string {
|
||||
const sections = [
|
||||
`=== ERROR DEBUG INFO ===`,
|
||||
`Timestamp: ${info.timestamp}`,
|
||||
``,
|
||||
`=== ERROR DETAILS ===`,
|
||||
`Type: ${info.error.type}`,
|
||||
`Message: ${info.error.message}`,
|
||||
info.error.stackTrace ? `Stack Trace:\n${info.error.stackTrace}` : "",
|
||||
``,
|
||||
`=== DEVICE INFO ===`,
|
||||
`Browser: ${info.device.browser} (${info.device.os})`,
|
||||
`User Agent: ${info.device.userAgent}`,
|
||||
`Screen: ${info.device.screenResolution}`,
|
||||
`Viewport: ${info.device.viewportSize}`,
|
||||
`Mobile: ${info.device.isMobile}`,
|
||||
`TV: ${info.device.isTV}`,
|
||||
``,
|
||||
`=== PLAYER STATE ===`,
|
||||
`Status: ${info.player.status}`,
|
||||
`Source ID: ${info.player.sourceId || "null"}`,
|
||||
`Quality: ${info.player.currentQuality || "null"}`,
|
||||
info.player.meta
|
||||
? [
|
||||
`Media: ${info.player.meta.title} (${info.player.meta.type})`,
|
||||
`TMDB ID: ${info.player.meta.tmdbId}`,
|
||||
info.player.meta.imdbId ? `IMDB ID: ${info.player.meta.imdbId}` : "",
|
||||
`Year: ${info.player.meta.releaseYear}`,
|
||||
info.player.meta.season ? `Season: ${info.player.meta.season}` : "",
|
||||
info.player.meta.episode
|
||||
? `Episode: ${info.player.meta.episode}`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
: "No media loaded",
|
||||
``,
|
||||
`=== NETWORK INFO ===`,
|
||||
`Online: ${info.network.online}`,
|
||||
info.network.connectionType
|
||||
? `Connection Type: ${info.network.connectionType}`
|
||||
: "",
|
||||
info.network.effectiveType
|
||||
? `Effective Type: ${info.network.effectiveType}`
|
||||
: "",
|
||||
info.network.downlink ? `Downlink: ${info.network.downlink} Mbps` : "",
|
||||
info.network.rtt ? `RTT: ${info.network.rtt} ms` : "",
|
||||
``,
|
||||
`=== PERFORMANCE ===`,
|
||||
info.performance.memory
|
||||
? [
|
||||
`Memory Used: ${Math.round(info.performance.memory.usedJSHeapSize / 1024 / 1024)} MB`,
|
||||
`Memory Total: ${Math.round(info.performance.memory.totalJSHeapSize / 1024 / 1024)} MB`,
|
||||
`Memory Limit: ${Math.round(info.performance.memory.jsHeapSizeLimit / 1024 / 1024)} MB`,
|
||||
].join("\n")
|
||||
: "Memory info not available",
|
||||
];
|
||||
|
||||
return sections.filter(Boolean).join("\n");
|
||||
}
|
||||
256
src/utils/externalSubtitles.ts
Normal file
256
src/utils/externalSubtitles.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
/* eslint-disable no-console */
|
||||
import { type SubtitleData, searchSubtitles } from "wyzie-lib";
|
||||
|
||||
import { CaptionListItem, PlayerMeta } from "@/stores/player/slices/source";
|
||||
|
||||
// Helper function to convert language names to language codes
|
||||
function labelToLanguageCode(languageName: string): string {
|
||||
const languageMap: Record<string, string> = {
|
||||
English: "en",
|
||||
Spanish: "es",
|
||||
French: "fr",
|
||||
German: "de",
|
||||
Italian: "it",
|
||||
Portuguese: "pt",
|
||||
Russian: "ru",
|
||||
Japanese: "ja",
|
||||
Korean: "ko",
|
||||
Chinese: "zh",
|
||||
Arabic: "ar",
|
||||
Hindi: "hi",
|
||||
Turkish: "tr",
|
||||
Dutch: "nl",
|
||||
Polish: "pl",
|
||||
Swedish: "sv",
|
||||
Norwegian: "no",
|
||||
Danish: "da",
|
||||
Finnish: "fi",
|
||||
Greek: "el",
|
||||
Hebrew: "he",
|
||||
Thai: "th",
|
||||
Vietnamese: "vi",
|
||||
Indonesian: "id",
|
||||
Malay: "ms",
|
||||
Filipino: "tl",
|
||||
Ukrainian: "uk",
|
||||
Romanian: "ro",
|
||||
Czech: "cs",
|
||||
Hungarian: "hu",
|
||||
Bulgarian: "bg",
|
||||
Croatian: "hr",
|
||||
Serbian: "sr",
|
||||
Slovak: "sk",
|
||||
Slovenian: "sl",
|
||||
Estonian: "et",
|
||||
Latvian: "lv",
|
||||
Lithuanian: "lt",
|
||||
Icelandic: "is",
|
||||
Maltese: "mt",
|
||||
Georgian: "ka",
|
||||
Armenian: "hy",
|
||||
Azerbaijani: "az",
|
||||
Kazakh: "kk",
|
||||
Kyrgyz: "ky",
|
||||
Uzbek: "uz",
|
||||
Tajik: "tg",
|
||||
Turkmen: "tk",
|
||||
Mongolian: "mn",
|
||||
Persian: "fa",
|
||||
Urdu: "ur",
|
||||
Bengali: "bn",
|
||||
Tamil: "ta",
|
||||
Telugu: "te",
|
||||
Marathi: "mr",
|
||||
Gujarati: "gu",
|
||||
Kannada: "kn",
|
||||
Malayalam: "ml",
|
||||
Punjabi: "pa",
|
||||
Sinhala: "si",
|
||||
Nepali: "ne",
|
||||
Burmese: "my",
|
||||
Khmer: "km",
|
||||
Lao: "lo",
|
||||
Tibetan: "bo",
|
||||
Uyghur: "ug",
|
||||
Kurdish: "ku",
|
||||
Pashto: "ps",
|
||||
Dari: "prs",
|
||||
Sindhi: "sd",
|
||||
Kashmiri: "ks",
|
||||
Dogri: "doi",
|
||||
Konkani: "kok",
|
||||
Manipuri: "mni",
|
||||
Bodo: "brx",
|
||||
Sanskrit: "sa",
|
||||
Santhali: "sat",
|
||||
Maithili: "mai",
|
||||
Bhojpuri: "bho",
|
||||
Awadhi: "awa",
|
||||
Chhattisgarhi: "hne",
|
||||
Magahi: "mag",
|
||||
Rajasthani: "raj",
|
||||
Malvi: "mup",
|
||||
Bundeli: "bns",
|
||||
Bagheli: "bfy",
|
||||
Pahari: "phr",
|
||||
Kumaoni: "kfy",
|
||||
Garhwali: "gbm",
|
||||
Kangri: "xnr",
|
||||
};
|
||||
|
||||
return languageMap[languageName] || languageName.toLowerCase();
|
||||
}
|
||||
|
||||
const timeout = (ms: number, source: string) =>
|
||||
new Promise<null>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.error(`${source} captions request timed out after ${ms}ms`);
|
||||
resolve(null);
|
||||
}, ms);
|
||||
});
|
||||
|
||||
export async function scrapeWyzieCaptions(
|
||||
tmdbId: string | number,
|
||||
imdbId: string,
|
||||
season?: number,
|
||||
episode?: number,
|
||||
): Promise<CaptionListItem[]> {
|
||||
try {
|
||||
const searchParams: any = {
|
||||
encoding: "utf-8",
|
||||
source: "all",
|
||||
imdb_id: imdbId,
|
||||
};
|
||||
|
||||
if (tmdbId && !imdbId) {
|
||||
searchParams.tmdb_id =
|
||||
typeof tmdbId === "string" ? parseInt(tmdbId, 10) : tmdbId;
|
||||
}
|
||||
|
||||
if (season && episode) {
|
||||
searchParams.season = season;
|
||||
searchParams.episode = episode;
|
||||
}
|
||||
|
||||
console.log("Searching Wyzie subtitles with params:", searchParams);
|
||||
const wyzieSubtitles: SubtitleData[] = await searchSubtitles(searchParams);
|
||||
|
||||
const wyzieCaptions: CaptionListItem[] = wyzieSubtitles.map((subtitle) => ({
|
||||
id: subtitle.id,
|
||||
language: subtitle.language,
|
||||
url: subtitle.url,
|
||||
type:
|
||||
subtitle.format === "srt" || subtitle.format === "vtt"
|
||||
? subtitle.format
|
||||
: "srt",
|
||||
needsProxy: false,
|
||||
opensubtitles: true,
|
||||
// Additional metadata from Wyzie
|
||||
display: subtitle.display,
|
||||
media: subtitle.media,
|
||||
isHearingImpaired: subtitle.isHearingImpaired,
|
||||
source:
|
||||
typeof subtitle.source === "number"
|
||||
? subtitle.source.toString()
|
||||
: subtitle.source,
|
||||
encoding: subtitle.encoding,
|
||||
}));
|
||||
|
||||
return wyzieCaptions;
|
||||
} catch (error) {
|
||||
console.error("Error fetching Wyzie subtitles:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrapeOpenSubtitlesCaptions(
|
||||
imdbId: string,
|
||||
season?: number,
|
||||
episode?: number,
|
||||
): Promise<CaptionListItem[]> {
|
||||
try {
|
||||
const url = `https://rest.opensubtitles.org/search/${
|
||||
season && episode ? `episode-${episode}/` : ""
|
||||
}imdbid-${imdbId.slice(2)}${season && episode ? `/season-${season}` : ""}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"X-User-Agent": "VLSub 0.10.2",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenSubtitles API returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const openSubtitlesCaptions: CaptionListItem[] = [];
|
||||
|
||||
for (const caption of data) {
|
||||
const downloadUrl = caption.SubDownloadLink.replace(".gz", "").replace(
|
||||
"download/",
|
||||
"download/subencoding-utf8/",
|
||||
);
|
||||
const language = labelToLanguageCode(caption.LanguageName);
|
||||
|
||||
if (!downloadUrl || !language) continue;
|
||||
|
||||
openSubtitlesCaptions.push({
|
||||
id: downloadUrl,
|
||||
language,
|
||||
url: downloadUrl,
|
||||
type: caption.SubFormat || "srt",
|
||||
needsProxy: false,
|
||||
opensubtitles: true,
|
||||
});
|
||||
}
|
||||
|
||||
return openSubtitlesCaptions;
|
||||
} catch (error) {
|
||||
console.error("Error fetching OpenSubtitles:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrapeExternalSubtitles(
|
||||
meta: PlayerMeta,
|
||||
): Promise<CaptionListItem[]> {
|
||||
try {
|
||||
// Extract IMDb ID from meta
|
||||
const imdbId = meta.imdbId;
|
||||
if (!imdbId) {
|
||||
console.log("No IMDb ID available for external subtitle scraping");
|
||||
return [];
|
||||
}
|
||||
|
||||
const season = meta.season?.number;
|
||||
const episode = meta.episode?.number;
|
||||
const tmdbId = meta.tmdbId;
|
||||
|
||||
// Fetch both Wyzie and OpenSubtitles captions with timeouts
|
||||
const [wyzieCaptions, openSubsCaptions] = await Promise.all([
|
||||
Promise.race([
|
||||
scrapeWyzieCaptions(tmdbId, imdbId, season, episode),
|
||||
timeout(2000, "Wyzie"),
|
||||
]),
|
||||
Promise.race([
|
||||
scrapeOpenSubtitlesCaptions(imdbId, season, episode),
|
||||
timeout(5000, "OpenSubtitles"),
|
||||
]),
|
||||
]);
|
||||
|
||||
const allCaptions: CaptionListItem[] = [];
|
||||
|
||||
if (wyzieCaptions) allCaptions.push(...wyzieCaptions);
|
||||
if (openSubsCaptions) allCaptions.push(...openSubsCaptions);
|
||||
|
||||
console.log(
|
||||
`Found ${allCaptions.length} external captions (Wyzie: ${wyzieCaptions?.length || 0}, OpenSubtitles: ${openSubsCaptions?.length || 0})`,
|
||||
);
|
||||
|
||||
return allCaptions;
|
||||
} catch (error) {
|
||||
console.error("Error in scrapeExternalSubtitles:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue