mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
feat: refactor platforms to support multiple TMDB provider IDs per platform
- Replace `tmdbProviderId` (single int) with a many-to-many `platformTmdbProviders` join table so one platform entry can map to multiple TMDB provider IDs (e.g. a single "Max" platform covers several regional TMDB IDs) - Add `isSubscription` flag to platforms to distinguish subscription services from transactional ones, replacing the old `displayOrder` field in API responses - Add `getPlatformTmdbIds` / `getPlatformTmdbIdMap` helpers in `@sofa/core/platforms` and thread them through discover, explore, and platform list procedures - Replace `providerId: number` with `platformId: string` (UUID) in discover input schema so the client never needs to resolve TMDB IDs - Add `scripts/sync-tmdb-providers.ts` script to pull live provider data from TMDB and update `platforms.json` - Split native title detail availability into separate "Stream" and "Buy or Rent" sections matching the web pattern - Add two new DB migrations for the join table and platform schema changes
This commit is contained in:
@@ -471,30 +471,76 @@ export default function TitleDetailScreen() {
|
||||
iconColor={titleAccent}
|
||||
/>
|
||||
</View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={titleAvailabilityContentStyle}
|
||||
>
|
||||
{availability.map((offer) => (
|
||||
<View key={`${offer.platformId}-${offer.offerType}`} className="items-center">
|
||||
{offer.logoPath && (
|
||||
<Image
|
||||
source={{ uri: offer.logoPath }}
|
||||
style={titleDetailStyles.providerLogo}
|
||||
contentFit="cover"
|
||||
/>
|
||||
{(() => {
|
||||
const stream = availability.filter((o) => o.offerType === "stream");
|
||||
const purchase = availability.filter((o) => o.offerType === "purchase");
|
||||
return (
|
||||
<View className="gap-3">
|
||||
{stream.length > 0 && (
|
||||
<View>
|
||||
<Text className="text-muted-foreground/60 mb-1.5 px-4 text-[10px] font-medium tracking-wider uppercase">
|
||||
{t`Stream`}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={titleAvailabilityContentStyle}
|
||||
>
|
||||
{stream.map((offer) => (
|
||||
<View key={offer.platformId} className="items-center">
|
||||
{offer.logoPath && (
|
||||
<Image
|
||||
source={{ uri: offer.logoPath }}
|
||||
style={titleDetailStyles.providerLogo}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
maxFontSizeMultiplier={1.0}
|
||||
className="text-muted-foreground mt-1 max-w-[60px] text-center text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{offer.providerName}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{purchase.length > 0 && (
|
||||
<View>
|
||||
<Text className="text-muted-foreground/60 mb-1.5 px-4 text-[10px] font-medium tracking-wider uppercase">
|
||||
{t`Buy or Rent`}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={titleAvailabilityContentStyle}
|
||||
>
|
||||
{purchase.map((offer) => (
|
||||
<View key={offer.platformId} className="items-center">
|
||||
{offer.logoPath && (
|
||||
<Image
|
||||
source={{ uri: offer.logoPath }}
|
||||
style={titleDetailStyles.providerLogo}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
maxFontSizeMultiplier={1.0}
|
||||
className="text-muted-foreground mt-1 max-w-[60px] text-center text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{offer.providerName}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
maxFontSizeMultiplier={1.0}
|
||||
className="text-muted-foreground mt-1 max-w-[60px] text-center text-xs"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{offer.providerName}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
})()}
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -58,14 +58,14 @@ function schedule(name: string, cron: string, handler: () => Promise<void>) {
|
||||
jobs.set(
|
||||
name,
|
||||
new Cron(cron, { name, protect: true }, async () => {
|
||||
log.info(`Running job: ${name}`);
|
||||
log.debug(`Running job: ${name}`);
|
||||
const startMs = performance.now();
|
||||
const run = startCronRun(name);
|
||||
try {
|
||||
await handler();
|
||||
const durationMs = Math.round(performance.now() - startMs);
|
||||
completeCronRun(run.id, durationMs);
|
||||
log.info(`Completed job: ${name} (${durationMs}ms)`);
|
||||
log.debug(`Completed job: ${name} (${durationMs}ms)`);
|
||||
} catch (err) {
|
||||
const durationMs = Math.round(performance.now() - startMs);
|
||||
failCronRun(run.id, durationMs, err);
|
||||
@@ -285,7 +285,7 @@ export function rescheduleBackup() {
|
||||
jobs.delete("scheduledBackup");
|
||||
}
|
||||
const cron = getBackupCronFromSettings();
|
||||
log.info(`Rescheduling backup job with cron: ${cron}`);
|
||||
log.debug(`Rescheduling backup job with cron: ${cron}`);
|
||||
schedule("scheduledBackup", cron, scheduledBackupJob);
|
||||
}
|
||||
|
||||
@@ -327,7 +327,7 @@ export function startJobs() {
|
||||
optimizeDatabase();
|
||||
});
|
||||
|
||||
log.info(`Started ${jobs.size} jobs`);
|
||||
log.debug(`Registered ${jobs.size} scheduled jobs`);
|
||||
}
|
||||
|
||||
export function pauseJobs() {
|
||||
|
||||
@@ -43,7 +43,7 @@ seedPlatforms();
|
||||
// Recover import jobs left in running/pending state from a previous crash
|
||||
const recoveredJobs = recoverStaleImportJobs();
|
||||
if (recoveredJobs > 0) {
|
||||
log.info(`Recovered ${recoveredJobs} stale import job(s) from previous shutdown`);
|
||||
log.warn(`Recovered ${recoveredJobs} stale import job(s) from previous shutdown`);
|
||||
}
|
||||
|
||||
// Wire up job schedule provider for system health
|
||||
@@ -157,9 +157,17 @@ process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
const port = Number(process.env.PORT || process.env.API_PORT || 3001);
|
||||
log.info(`API server listening on port ${port}`);
|
||||
|
||||
export default {
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
});
|
||||
|
||||
log.info(
|
||||
`🍿 Sofa${process.env.APP_VERSION ? ` v${process.env.APP_VERSION}` : ""}: Now playing at ${server.url.origin}`,
|
||||
{
|
||||
address: server.hostname,
|
||||
port: server.port,
|
||||
},
|
||||
);
|
||||
|
||||
export default server;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ORPCError } from "@orpc/server";
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { WATCH_REGION } from "@sofa/config";
|
||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||
import { getPlatformTmdbIds } from "@sofa/core/platforms";
|
||||
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { discover as discoverTmdb } from "@sofa/tmdb/client";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
@@ -34,9 +35,12 @@ export const discover = os.discover.use(authed).handler(async ({ input, context
|
||||
}
|
||||
if (input.ratingMin != null) params["vote_average.gte"] = String(input.ratingMin);
|
||||
if (input.language) params.with_original_language = input.language;
|
||||
if (input.providerId) {
|
||||
params.with_watch_providers = String(input.providerId);
|
||||
params.watch_region = WATCH_REGION;
|
||||
if (input.platformId) {
|
||||
const tmdbIds = getPlatformTmdbIds(input.platformId);
|
||||
if (tmdbIds.length > 0) {
|
||||
params.with_watch_providers = tmdbIds.join("|");
|
||||
params.watch_region = WATCH_REGION;
|
||||
}
|
||||
}
|
||||
|
||||
const results = await discoverTmdb(input.type, params, input.page);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ORPCError } from "@orpc/server";
|
||||
|
||||
import { AppErrorCode } from "@sofa/api/errors";
|
||||
import { ensureBrowseTitlesExist } from "@sofa/core/metadata";
|
||||
import { listPlatforms } from "@sofa/core/platforms";
|
||||
import { getPlatformTmdbIdMap, listPlatforms } from "@sofa/core/platforms";
|
||||
import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking";
|
||||
import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client";
|
||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||
@@ -165,10 +165,11 @@ export const genres = os.explore.genres.use(authed).handler(async ({ input }) =>
|
||||
|
||||
export const watchProviders = os.explore.watchProviders.use(authed).handler(async () => {
|
||||
const allPlatforms = listPlatforms();
|
||||
const tmdbIdsMap = getPlatformTmdbIdMap(allPlatforms.map((p) => p.id));
|
||||
return {
|
||||
providers: allPlatforms.map((p) => ({
|
||||
id: p.id,
|
||||
tmdbProviderId: p.tmdbProviderId,
|
||||
tmdbProviderIds: tmdbIdsMap.get(p.id) ?? [],
|
||||
name: p.name,
|
||||
logoPath: tmdbImageUrl(p.logoPath, "logos"),
|
||||
})),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { listPlatforms } from "@sofa/core/platforms";
|
||||
import { listPlatforms, getPlatformTmdbIdMap } from "@sofa/core/platforms";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
|
||||
import { os } from "../context";
|
||||
@@ -6,13 +6,14 @@ import { authed } from "../middleware";
|
||||
|
||||
export const list = os.platforms.list.use(authed).handler(async () => {
|
||||
const allPlatforms = listPlatforms();
|
||||
const tmdbIdsMap = getPlatformTmdbIdMap(allPlatforms.map((p) => p.id));
|
||||
return {
|
||||
platforms: allPlatforms.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
tmdbProviderId: p.tmdbProviderId,
|
||||
tmdbProviderIds: tmdbIdsMap.get(p.id) ?? [],
|
||||
logoPath: tmdbImageUrl(p.logoPath, "logos"),
|
||||
displayOrder: p.displayOrder,
|
||||
isSubscription: p.isSubscription,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -109,8 +109,6 @@ export function Sparkline({ data, color }: SparklineProps) {
|
||||
return computeMonotonePath(points, size.height);
|
||||
}, [data, size.width, size.height]);
|
||||
|
||||
if (!data.some((d) => d.count > 0)) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
||||
@@ -108,7 +108,7 @@ function PeriodSelect({
|
||||
{(value: TimePeriod | null) => (value ? periodLabels[value] : null)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent align="start" className="p-1">
|
||||
{periods.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{periodLabels[p]}
|
||||
|
||||
@@ -67,8 +67,7 @@ export function DiscoverSection() {
|
||||
| "primary_release_date.asc";
|
||||
const [sortBy, setSortBy] = useState<DiscoverSortBy | undefined>(undefined);
|
||||
const [language, setLanguage] = useState<string | undefined>(undefined);
|
||||
const [providerId, setProviderId] = useState<number | undefined>(undefined);
|
||||
const [selectedPlatformId, setSelectedPlatformId] = useState("");
|
||||
const [platformId, setPlatformId] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data: genreData } = useQuery(orpc.explore.genres.queryOptions({ input: { type } }));
|
||||
const { data: providerData } = useQuery(orpc.platforms.list.queryOptions());
|
||||
@@ -83,7 +82,7 @@ export function DiscoverSection() {
|
||||
ratingMin,
|
||||
sortBy,
|
||||
language,
|
||||
providerId,
|
||||
platformId,
|
||||
page: pageParam,
|
||||
}),
|
||||
initialPageParam: 1,
|
||||
@@ -154,14 +153,7 @@ export function DiscoverSection() {
|
||||
}
|
||||
|
||||
function handleProviderChange(value: string | null) {
|
||||
setSelectedPlatformId(value ?? "");
|
||||
if (!value) {
|
||||
setProviderId(undefined);
|
||||
return;
|
||||
}
|
||||
// Resolve platform UUID to TMDB provider ID for the discover API
|
||||
const platform = providers.find((p) => p.id === value);
|
||||
setProviderId(platform?.tmdbProviderId ?? undefined);
|
||||
setPlatformId(value || undefined);
|
||||
}
|
||||
|
||||
function handleGenreChange(value: string | null) {
|
||||
@@ -183,8 +175,7 @@ export function DiscoverSection() {
|
||||
if (next === "movie" || next === "tv") {
|
||||
setType(next);
|
||||
setGenreId(undefined);
|
||||
setProviderId(undefined);
|
||||
setSelectedPlatformId("");
|
||||
setPlatformId(undefined);
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
@@ -217,7 +208,7 @@ export function DiscoverSection() {
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="">{t`All genres`}</SelectItem>
|
||||
{genres.map((genre) => (
|
||||
<SelectItem key={genre.id} value={String(genre.id)}>
|
||||
@@ -248,7 +239,7 @@ export function DiscoverSection() {
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="">{t`Any year`}</SelectItem>
|
||||
{DECADE_PRESETS.map((d) => (
|
||||
<SelectItem key={d.min} value={String(d.min)}>
|
||||
@@ -277,7 +268,7 @@ export function DiscoverSection() {
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="">{t`Any rating`}</SelectItem>
|
||||
{RATING_PRESETS.map((r) => (
|
||||
<SelectItem key={r.value} value={String(r.value)}>
|
||||
@@ -306,7 +297,7 @@ export function DiscoverSection() {
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="">{t`Default`}</SelectItem>
|
||||
{SORT_OPTIONS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
@@ -335,7 +326,7 @@ export function DiscoverSection() {
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="">{t`Any language`}</SelectItem>
|
||||
{LANGUAGE_OPTIONS.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
@@ -347,14 +338,14 @@ export function DiscoverSection() {
|
||||
|
||||
{/* Provider select */}
|
||||
<Select
|
||||
value={selectedPlatformId}
|
||||
value={platformId ?? ""}
|
||||
onValueChange={handleProviderChange}
|
||||
modal={false}
|
||||
aria-label={t`Provider`}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
data-active={selectedPlatformId ? "" : undefined}
|
||||
data-active={platformId ? "" : undefined}
|
||||
className="data-[active]:border-primary/40 data-[active]:text-foreground"
|
||||
>
|
||||
<SelectValue>
|
||||
@@ -365,10 +356,10 @@ export function DiscoverSection() {
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="">{t`All providers`}</SelectItem>
|
||||
{providers
|
||||
.filter((p) => p.tmdbProviderId != null)
|
||||
.filter((p) => p.tmdbProviderIds.length > 0)
|
||||
.map((platform) => (
|
||||
<SelectItem key={platform.id} value={platform.id}>
|
||||
{platform.name}
|
||||
|
||||
@@ -206,7 +206,7 @@ export function LibraryFilters({
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="">{t`All genres`}</SelectItem>
|
||||
{genreData?.genres.map((genre) => (
|
||||
<SelectItem key={genre.id} value={String(genre.id)}>
|
||||
@@ -235,7 +235,7 @@ export function LibraryFilters({
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="">{t`Any`}</SelectItem>
|
||||
<SelectItem value="1">1★+</SelectItem>
|
||||
<SelectItem value="2">2★+</SelectItem>
|
||||
@@ -261,14 +261,14 @@ export function LibraryFilters({
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="">{t`Any year`}</SelectItem>
|
||||
{DECADES.map((d) => (
|
||||
<SelectItem key={d.yearMin} value={String(d.yearMin)}>
|
||||
{d.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="older">{t`Pre-1980`}</SelectItem>
|
||||
<SelectItem value="older">{t`70s and earlier`}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -286,7 +286,7 @@ export function LibraryFilters({
|
||||
>
|
||||
<SelectValue>{(value: string | null) => (value ? value : t`Age`)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="">{t`All`}</SelectItem>
|
||||
{CONTENT_RATINGS.map((rating) => (
|
||||
<SelectItem key={rating} value={rating}>
|
||||
|
||||
@@ -68,20 +68,20 @@ export function LibraryToolbar({
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t`Search library...`}
|
||||
className="pl-7"
|
||||
className="py-3 pl-7"
|
||||
aria-label={t`Search library`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
<span className="text-muted-foreground text-[13px]">
|
||||
{plural(totalResults, { one: "# result", other: "# results" })}
|
||||
</span>
|
||||
|
||||
{/* Filter toggle */}
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline">
|
||||
<IconFilter aria-hidden={true} className="size-3.5" />
|
||||
{t`Filters`}
|
||||
{activeFilterCount > 0 && (
|
||||
@@ -101,7 +101,7 @@ export function LibraryToolbar({
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline">
|
||||
<IconSortDescending aria-hidden={true} className="size-3.5" />
|
||||
{t`Sort`}
|
||||
</Button>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function FilmographyGrid({ credits, userStatuses }: FilmographyGridProps)
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent align="end" className="p-1">
|
||||
<SelectItem value="newest">
|
||||
<Trans>Newest</Trans>
|
||||
</SelectItem>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function PlatformGrid({ platforms, selectedIds, onToggle }: PlatformGridP
|
||||
className={`group relative flex flex-col items-center gap-2 rounded-xl border p-3 motion-safe:transition-colors motion-safe:duration-150 ${
|
||||
isSelected
|
||||
? "border-primary/50 bg-primary/8"
|
||||
: "border-border/20 hover:border-primary/30 hover:bg-primary/5"
|
||||
: "border-border/50 hover:border-primary/30 hover:bg-primary/5"
|
||||
}`}
|
||||
aria-label={(() => {
|
||||
const name = platform.name;
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
IconLogout,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
@@ -164,208 +163,197 @@ export function AccountSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<IconUser aria-hidden={true} className="text-muted-foreground size-4" />
|
||||
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||
<Trans>Account</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
<Card className="pb-0">
|
||||
<CardContent className="flex items-center gap-4">
|
||||
{/* Avatar: click to upload (no avatar) or remove (has avatar) */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={avatarUrl ? handleRemoveAvatar : () => fileInputRef.current?.click()}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
disabled={isAvatarPending}
|
||||
/>
|
||||
}
|
||||
className="focus-visible:ring-ring focus-visible:ring-offset-background relative shrink-0 cursor-pointer rounded-full focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label={avatarUrl ? t`Remove profile picture` : t`Upload profile picture`}
|
||||
>
|
||||
<Avatar className="size-12 overflow-hidden">
|
||||
<AvatarImage src={isAvatarPending ? undefined : avatarUrl} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/10 font-display text-primary text-lg">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Card className="pb-0">
|
||||
<CardContent className="flex items-center gap-4">
|
||||
{/* Avatar: click to upload (no avatar) or remove (has avatar) */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={avatarUrl ? handleRemoveAvatar : () => fileInputRef.current?.click()}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
disabled={isAvatarPending}
|
||||
/>
|
||||
}
|
||||
className="focus-visible:ring-ring focus-visible:ring-offset-background relative shrink-0 cursor-pointer rounded-full focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
aria-label={avatarUrl ? t`Remove profile picture` : t`Upload profile picture`}
|
||||
>
|
||||
<Avatar className="size-12 overflow-hidden">
|
||||
<AvatarImage src={isAvatarPending ? undefined : avatarUrl} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/10 font-display text-primary text-lg">
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<AnimatePresence>
|
||||
{(isHovered || isAvatarPending) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`text-foreground/70 absolute inset-0 flex items-center justify-center rounded-full backdrop-blur-sm ${
|
||||
avatarUrl && !isAvatarPending ? "bg-destructive/40" : "bg-black/50"
|
||||
}`}
|
||||
>
|
||||
{isAvatarPending ? (
|
||||
<Spinner className="size-4.5" />
|
||||
) : avatarUrl ? (
|
||||
<IconTrash className="size-4.5" />
|
||||
) : (
|
||||
<IconCamera className="size-4.5" />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{avatarUrl ? <Trans>Remove picture</Trans> : <Trans>Upload picture</Trans>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="mb-0.5">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isEditingName ? (
|
||||
<motion.div
|
||||
key="editing"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<div className="relative inline-grid items-center">
|
||||
<span
|
||||
className="invisible col-start-1 row-start-1 text-sm font-medium whitespace-pre"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{editValue || " "}
|
||||
</span>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
onBlur={handleNameSave}
|
||||
disabled={isNamePending}
|
||||
maxLength={100}
|
||||
className="border-primary/40 focus:border-primary col-start-1 row-start-1 min-w-4 border-0 border-b border-dashed bg-transparent text-sm font-medium transition-colors outline-none"
|
||||
/>
|
||||
</div>
|
||||
{isNamePending ? (
|
||||
<Spinner className="text-muted-foreground size-3.5 shrink-0" />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleNameSave();
|
||||
}}
|
||||
className="text-muted-foreground hover:text-primary shrink-0 rounded-md p-0.5 transition-colors"
|
||||
aria-label={t`Save name`}
|
||||
>
|
||||
<IconCheck className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleNameCancel();
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive shrink-0 rounded-md p-0.5 transition-colors"
|
||||
aria-label={t`Cancel editing`}
|
||||
>
|
||||
<IconX className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.button
|
||||
key="display"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
type="button"
|
||||
onClick={() => setIsEditingName(true)}
|
||||
className="group/name hover:text-primary inline-flex items-center gap-1.5 rounded-md px-0 text-start transition-colors"
|
||||
>
|
||||
{displayName}
|
||||
<IconPencil className="group-hover/name:text-muted-foreground size-3 text-transparent transition-colors" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{user.email}
|
||||
{user.role === "admin" && (
|
||||
<Badge className="bg-primary/10 text-primary ms-1.5 rounded-md border-0 align-middle">
|
||||
<Trans>Admin</Trans>
|
||||
</Badge>
|
||||
<AnimatePresence>
|
||||
{(isHovered || isAvatarPending) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`text-foreground/70 absolute inset-0 flex items-center justify-center rounded-full backdrop-blur-sm ${
|
||||
avatarUrl && !isAvatarPending ? "bg-destructive/40" : "bg-black/50"
|
||||
}`}
|
||||
>
|
||||
{isAvatarPending ? (
|
||||
<Spinner className="size-4.5" />
|
||||
) : avatarUrl ? (
|
||||
<IconTrash className="size-4.5" />
|
||||
) : (
|
||||
<IconCamera className="size-4.5" />
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</CardDescription>
|
||||
<p className="text-muted-foreground/60 mt-0.5 text-xs">
|
||||
<Trans>Member since {memberSince}</Trans>
|
||||
</AnimatePresence>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{avatarUrl ? <Trans>Remove picture</Trans> : <Trans>Upload picture</Trans>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="mb-0.5">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isEditingName ? (
|
||||
<motion.div
|
||||
key="editing"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<div className="relative inline-grid items-center">
|
||||
<span
|
||||
className="invisible col-start-1 row-start-1 text-sm font-medium whitespace-pre"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{editValue || " "}
|
||||
</span>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
onBlur={handleNameSave}
|
||||
disabled={isNamePending}
|
||||
maxLength={100}
|
||||
className="border-primary/40 focus:border-primary col-start-1 row-start-1 min-w-4 border-0 border-b border-dashed bg-transparent text-sm font-medium transition-colors outline-none"
|
||||
/>
|
||||
</div>
|
||||
{isNamePending ? (
|
||||
<Spinner className="text-muted-foreground size-3.5 shrink-0" />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleNameSave();
|
||||
}}
|
||||
className="text-muted-foreground hover:text-primary shrink-0 rounded-md p-0.5 transition-colors"
|
||||
aria-label={t`Save name`}
|
||||
>
|
||||
<IconCheck className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleNameCancel();
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive shrink-0 rounded-md p-0.5 transition-colors"
|
||||
aria-label={t`Cancel editing`}
|
||||
>
|
||||
<IconX className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.button
|
||||
key="display"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
type="button"
|
||||
onClick={() => setIsEditingName(true)}
|
||||
className="group/name hover:text-primary inline-flex items-center gap-1.5 rounded-md px-0 text-start transition-colors"
|
||||
>
|
||||
{displayName}
|
||||
<IconPencil className="group-hover/name:text-muted-foreground size-3 text-transparent transition-colors" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{user.email}
|
||||
{user.role === "admin" && (
|
||||
<Badge className="bg-primary/10 text-primary ms-1.5 rounded-md border-0 align-middle">
|
||||
<Trans>Admin</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</CardDescription>
|
||||
<p className="text-muted-foreground/60 mt-0.5 text-xs">
|
||||
<Trans>Member since {memberSince}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
|
||||
<ChangePasswordDialog />
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
void navigate({ to: "/" });
|
||||
}}
|
||||
>
|
||||
<IconLogout aria-hidden={true} />
|
||||
<Trans>Sign out</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-border/30 space-y-px border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/export/user-data";
|
||||
}}
|
||||
className="group hover:bg-muted/40 flex w-full items-center gap-3 px-4 py-3 text-start transition-colors"
|
||||
>
|
||||
<div className="bg-muted group-hover:bg-primary/10 flex size-7.5 shrink-0 items-center justify-center rounded-lg">
|
||||
<IconCloudDownload
|
||||
aria-hidden={true}
|
||||
className="text-muted-foreground group-hover:text-primary size-3.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-[13px] leading-none font-medium">
|
||||
<Trans>Export</Trans>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<Trans>Download your library, watch history, and ratings as JSON</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
|
||||
<ChangePasswordDialog />
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
void navigate({ to: "/" });
|
||||
}}
|
||||
>
|
||||
<IconLogout aria-hidden={true} />
|
||||
<Trans>Sign out</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-border/30 space-y-px border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.href = "/api/export/user-data";
|
||||
}}
|
||||
className="group hover:bg-muted/40 flex w-full items-center gap-3 px-4 py-3 text-start transition-colors"
|
||||
>
|
||||
<div className="bg-muted group-hover:bg-primary/10 flex size-7.5 shrink-0 items-center justify-center rounded-lg">
|
||||
<IconCloudDownload
|
||||
aria-hidden={true}
|
||||
className="text-muted-foreground group-hover:text-primary size-3.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-[13px] leading-none font-medium">
|
||||
<Trans>Export</Trans>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
<Trans>Download your library, watch history, and ratings as JSON</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<IconArrowRight
|
||||
aria-hidden={true}
|
||||
className="text-muted-foreground size-3.5 shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<SofaImportDialog />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<IconArrowRight aria-hidden={true} className="text-muted-foreground size-3.5 shrink-0" />
|
||||
</button>
|
||||
<SofaImportDialog />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ export function BackupScheduleSection() {
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent align="start" className="p-1">
|
||||
{[3, 5, 7, 14, 30, 0].map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n === 0 ? t`unlimited` : t`last ${n}`}
|
||||
@@ -319,7 +319,7 @@ export function BackupScheduleSection() {
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent align="start" className="p-1">
|
||||
{DAYS_OF_WEEK.map((day, i) => (
|
||||
<SelectItem key={day} value={String(i)}>
|
||||
{day}
|
||||
@@ -368,7 +368,7 @@ export function BackupScheduleSection() {
|
||||
}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" alignItemWithTrigger={false} className="p-1">
|
||||
<SelectContent align="start" className="p-1">
|
||||
{HOURS.map((h) => {
|
||||
const val = `${String(h).padStart(2, "0")}:00`;
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { IconCloudUpload, IconFileImport, IconLink } from "@tabler/icons-react";
|
||||
import { IconCloudUpload, IconLink } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -99,7 +99,7 @@ const SOURCES: SourceConfig[] = [
|
||||
{
|
||||
source: "trakt",
|
||||
label: "Trakt",
|
||||
description: "Connect your Trakt account or upload a JSON export.",
|
||||
description: "Connect your Trakt account or upload a JSON export",
|
||||
accept: ".json",
|
||||
icon: <TraktLogo className="text-primary size-4" />,
|
||||
supportsOAuth: true,
|
||||
@@ -107,7 +107,7 @@ const SOURCES: SourceConfig[] = [
|
||||
{
|
||||
source: "simkl",
|
||||
label: "Simkl",
|
||||
description: "Connect your Simkl account or upload a JSON export.",
|
||||
description: "Connect your Simkl account or upload a JSON export",
|
||||
accept: ".json",
|
||||
icon: <SimklLogo className="text-primary size-4" />,
|
||||
supportsOAuth: true,
|
||||
@@ -115,7 +115,7 @@ const SOURCES: SourceConfig[] = [
|
||||
{
|
||||
source: "letterboxd",
|
||||
label: "Letterboxd",
|
||||
description: "Upload the ZIP export from your Letterboxd account settings.",
|
||||
description: "Upload the ZIP export from your Letterboxd account settings",
|
||||
accept: ".zip",
|
||||
icon: <LetterboxdLogo className="text-primary size-4" />,
|
||||
supportsOAuth: false,
|
||||
@@ -168,18 +168,10 @@ type DialogStep =
|
||||
|
||||
export function ImportsSection() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<IconFileImport aria-hidden className="text-muted-foreground size-4" />
|
||||
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||
<Trans>Import</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{SOURCES.map((config) => (
|
||||
<ImportSourceCard key={config.source} config={config} />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{SOURCES.map((config) => (
|
||||
<ImportSourceCard key={config.source} config={config} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { IconWebhook } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -25,35 +23,24 @@ export function IntegrationsSection() {
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<IconWebhook aria-hidden={true} className="text-muted-foreground size-4" />
|
||||
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||
<Trans>Integrations</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<div className="space-y-2.5">
|
||||
{INTEGRATION_CONFIGS.map((c) => (
|
||||
<Skeleton key={c.provider} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{INTEGRATION_CONFIGS.map((config) => (
|
||||
<IntegrationCard
|
||||
key={config.provider}
|
||||
config={config}
|
||||
connection={
|
||||
connections.find((c: IntegrationConnection) => c.provider === config.provider) ??
|
||||
null
|
||||
}
|
||||
setConnections={handleSetConnections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
return isPending ? (
|
||||
<div className="space-y-2.5">
|
||||
{INTEGRATION_CONFIGS.map((c) => (
|
||||
<Skeleton key={c.provider} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{INTEGRATION_CONFIGS.map((config) => (
|
||||
<IntegrationCard
|
||||
key={config.provider}
|
||||
config={config}
|
||||
connection={
|
||||
connections.find((c: IntegrationConnection) => c.provider === config.provider) ?? null
|
||||
}
|
||||
setConnections={handleSetConnections}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const sectionVariants = {
|
||||
export function SettingsShell({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="mx-auto max-w-2xl space-y-6"
|
||||
className="mx-auto max-w-2xl space-y-8"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { IconChevronDown, IconDeviceTv } from "@tabler/icons-react";
|
||||
import { IconChevronDown, IconCircleCheck, IconDeviceTv } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { SVGProps, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { PlatformGrid } from "@/components/platforms/platform-grid";
|
||||
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||
@@ -86,26 +86,27 @@ export function StreamingServicesSection() {
|
||||
</div>
|
||||
<div className="text-start">
|
||||
<CardTitle>
|
||||
<Trans>Streaming Services</Trans>
|
||||
<Trans>Subscriptions</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedCount > 0 ? (
|
||||
t`${selectedCount} selected`
|
||||
) : (
|
||||
<Trans>Choose your subscriptions</Trans>
|
||||
<Trans>Choose your preferred streaming platforms</Trans>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<AnimatePresence>
|
||||
{saved && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: 4 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 4 }}
|
||||
className="text-primary text-xs"
|
||||
className="text-primary flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<IconCircleCheck className="size-3.5" />
|
||||
<Trans>Saved</Trans>
|
||||
</motion.span>
|
||||
)}
|
||||
@@ -121,9 +122,38 @@ export function StreamingServicesSection() {
|
||||
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0">
|
||||
<CardContent className="border-border/30 border-t pt-4">
|
||||
<PlatformGrid platforms={platforms} selectedIds={selectedIds} onToggle={handleToggle} />
|
||||
<div className="text-muted-foreground mt-4 text-center text-xs">
|
||||
<a
|
||||
href="https://justwatch.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary"
|
||||
>
|
||||
<Trans>
|
||||
Streaming data provided by{" "}
|
||||
<JustWatchLogo
|
||||
className="mx-0.5 inline-block size-3.5 translate-y-[-1px] text-[#FBD446]"
|
||||
aria-hidden={true}
|
||||
/>
|
||||
<span className="font-semibold">JustWatch</span>
|
||||
</Trans>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function JustWatchLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
|
||||
{/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11.727 11.7L8.1 9.85s-.646-.344-.617.38s0 3.591 0 3.591s-.1.767.612.427s3.094-1.571 3.627-1.841c.715-.367.005-.707.005-.707m-.006 5.023l-3.628-1.854s-.647-.345-.618.38s0 3.591 0 3.591s-.1.768.613.427s3.093-1.571 3.626-1.841c.717-.367.007-.703.007-.703m4.953-2.464l-3.628-1.854s-.647-.344-.617.38s-.005 3.591-.005 3.591s-.1.768.612.427s3.094-1.57 3.626-1.841c.722-.362.012-.703.012-.703m4.205-2.918L17.844 9.8c-.506-.257-.457.333-.457.333v3.824s-.054.564.468.3l3.025-1.526c1.398-.702-.001-1.39-.001-1.39m-4.203-2.229l-3.629-1.855s-.647-.343-.617.38s0 3.592 0 3.592s-.1.767.612.427s3.094-1.571 3.626-1.841c.717-.367.008-.703.008-.703M7.012 4.257s-1.494-.74-2.945-1.479s-1.539.693-1.539.693v3.128c0 .369.373.217.373.217s3.7-1.886 4.113-2.1s-.002-.459-.002-.459m1.071 4.924c.715-.34 3.094-1.57 3.626-1.841c.722-.366.013-.7.013-.7L8.093 4.783s-.646-.344-.618.38s0 3.591 0 3.591s-.107.768.608.427m-4.961 2.573c.716-.341 3.094-1.571 3.626-1.841c.723-.367.013-.7.013-.7L3.133 7.355s-.647-.343-.618.38s-.005 3.592-.005 3.592s-.103.767.612.427m-.005 4.954c.716-.34 3.094-1.57 3.627-1.841c.722-.366.012-.7.012-.7L3.128 12.31s-.647-.343-.618.38s0 3.591 0 3.591v.101c-.01.196.058.588.607.326M7 19.334c-.418-.216-4.115-2.1-4.115-2.1s-.374-.151-.373.218l.006 3.128s.09 1.434 1.54.692S7 19.791 7 19.791s.415-.24 0-.457"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export function SystemHealthCards() {
|
||||
if (isPending || !data) return <SkeletonCards />;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
<SystemStatusCard
|
||||
checkedAt={data.checkedAt}
|
||||
database={data.database}
|
||||
|
||||
@@ -17,19 +17,13 @@ function ProviderBadge({
|
||||
logoPath: string | null;
|
||||
watchUrl: string | null;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
{...(watchUrl
|
||||
? {
|
||||
render: (
|
||||
<a
|
||||
href={watchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t`Watch on ${name}`}
|
||||
/>
|
||||
<a href={watchUrl} target="_blank" rel="noopener noreferrer" aria-label={name} />
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
@@ -50,7 +44,7 @@ function ProviderBadge({
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-popover text-popover-foreground px-2 py-1 text-[10px] font-medium shadow-md [&>:last-child]:hidden">
|
||||
{watchUrl ? t`Watch on ${name}` : name}
|
||||
{name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -159,11 +153,8 @@ function OffersByType({
|
||||
export function TitleAvailability({ availability }: { availability: AvailabilityOffer[] }) {
|
||||
const { t } = useLingui();
|
||||
const offerLabels: Record<string, string> = {
|
||||
flatrate: t`Stream`,
|
||||
rent: t`Rent`,
|
||||
buy: t`Buy`,
|
||||
free: t`Free`,
|
||||
ads: t`With Ads`,
|
||||
stream: t`Stream`,
|
||||
purchase: t`Buy or Rent`,
|
||||
};
|
||||
|
||||
if (availability.length === 0) return null;
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconDatabaseExport,
|
||||
IconDeviceDesktopCog,
|
||||
IconFileImport,
|
||||
IconServer2,
|
||||
IconShieldLock,
|
||||
IconUser,
|
||||
IconWebhook,
|
||||
} from "@tabler/icons-react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
@@ -77,78 +79,50 @@ function SettingsPage() {
|
||||
const isAdmin = session.user.role === "admin";
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
footer={
|
||||
<footer className="border-border/50 text-muted-foreground border-t pt-6 pb-2 text-center text-xs">
|
||||
<p>
|
||||
<a
|
||||
href="https://sofa.watch"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary/80 hover:text-primary font-medium transition-colors"
|
||||
>
|
||||
Sofa
|
||||
</a>{" "}
|
||||
v{__APP_VERSION__}
|
||||
{__GIT_COMMIT_SHA__ && (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
<a
|
||||
href={`https://github.com/${GITHUB_REPO}/commit/${__GIT_COMMIT_SHA__}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary font-mono transition-colors"
|
||||
>
|
||||
{__GIT_COMMIT_SHA__}
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col items-center gap-2">
|
||||
<a
|
||||
href="https://www.themoviedb.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition-opacity hover:opacity-70"
|
||||
>
|
||||
<TmdbLogo className="h-3" />
|
||||
</a>
|
||||
<p className="text-muted-foreground text-[10px] leading-relaxed">
|
||||
<Trans>
|
||||
This product uses the TMDB API but is not endorsed or certified by TMDB.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
>
|
||||
<AccountSection
|
||||
user={{
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
image: session.user.image || undefined,
|
||||
createdAt: new Date(session.user.createdAt).toISOString(),
|
||||
role: session.user.role ?? undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* App Settings */}
|
||||
<SettingsShell>
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<IconDeviceDesktopCog aria-hidden={true} className="text-muted-foreground size-4" />
|
||||
<IconUser aria-hidden={true} className="text-muted-foreground size-4" />
|
||||
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||
<Trans>App Settings</Trans>
|
||||
<Trans>Account</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
<LanguageSection />
|
||||
<StreamingServicesSection />
|
||||
<div className="space-y-2.5">
|
||||
<AccountSection
|
||||
user={{
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
image: session.user.image || undefined,
|
||||
createdAt: new Date(session.user.createdAt).toISOString(),
|
||||
role: session.user.role ?? undefined,
|
||||
}}
|
||||
/>
|
||||
<StreamingServicesSection />
|
||||
<LanguageSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IntegrationsSection />
|
||||
{/* Integrations */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<IconWebhook aria-hidden={true} className="text-muted-foreground size-4" />
|
||||
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||
<Trans>Integrations</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
<IntegrationsSection />
|
||||
</div>
|
||||
|
||||
<ImportsSection />
|
||||
{/* Import */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<IconFileImport aria-hidden className="text-muted-foreground size-4" />
|
||||
<h2 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||
<Trans>Import</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
<ImportsSection />
|
||||
</div>
|
||||
|
||||
{/* Server health */}
|
||||
{isAdmin && (
|
||||
@@ -178,7 +152,7 @@ function SettingsPage() {
|
||||
<Trans>Admin only</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
<Card className="border-s-primary/30 border-s-2">
|
||||
<RegistrationSection />
|
||||
</Card>
|
||||
@@ -201,7 +175,7 @@ function SettingsPage() {
|
||||
<Trans>Admin only</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2.5">
|
||||
<Card className="border-s-primary/30 border-s-2">
|
||||
<BackupSection />
|
||||
</Card>
|
||||
@@ -232,6 +206,48 @@ function SettingsPage() {
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="border-border/50 text-muted-foreground border-t pt-6 pb-2 text-center text-xs">
|
||||
<p>
|
||||
<a
|
||||
href="https://sofa.watch"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary/80 hover:text-primary font-medium transition-colors"
|
||||
>
|
||||
Sofa
|
||||
</a>{" "}
|
||||
v{__APP_VERSION__}
|
||||
{__GIT_COMMIT_SHA__ && (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
<a
|
||||
href={`https://github.com/${GITHUB_REPO}/commit/${__GIT_COMMIT_SHA__}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary font-mono transition-colors"
|
||||
>
|
||||
{__GIT_COMMIT_SHA__}
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col items-center gap-2">
|
||||
<a
|
||||
href="https://www.themoviedb.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition-opacity hover:opacity-70"
|
||||
>
|
||||
<TmdbLogo className="h-3" />
|
||||
</a>
|
||||
<p className="text-muted-foreground text-[10px] leading-relaxed">
|
||||
<Trans>This product uses the TMDB API but is not endorsed or certified by TMDB.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user