mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -0,0 +1,12 @@
|
||||
import { Spinner } from './ui/spinner'
|
||||
|
||||
const APP_NAME = import.meta.env.VITE_APP_NAME ?? 'STARR'
|
||||
|
||||
export default function AppLoadingScreen() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-background">
|
||||
<Spinner className="size-10 text-primary" />
|
||||
<p className="mt-4 text-sm text-muted-foreground tracking-wide">Loading {APP_NAME}...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -125,8 +125,12 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
||||
useEffect(() => {
|
||||
if (!assets.length) return;
|
||||
|
||||
// Only request tokens for S3 assets we don't already have a URL for —
|
||||
// this prevents duplicate POST /tokens when the assets list triggers
|
||||
// this effect more than once per sheet open (e.g., two fetch effects
|
||||
// both reacting to open mounting, producing two assets updates).
|
||||
const s3Ids = assets
|
||||
.filter((a) => a.storage_provider === "s3" && !a.thumbnail_url && !a.file_url)
|
||||
.filter((a) => a.storage_provider === "s3" && !streamUrls[String(a.asset_id)])
|
||||
.map((a) => a.asset_id);
|
||||
|
||||
if (!s3Ids.length) return;
|
||||
@@ -135,10 +139,12 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
||||
api.post("/admin/media/tokens", { asset_ids: s3Ids })
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return;
|
||||
const tokens = data.data?.tokens ?? {};
|
||||
const tokens = data.data?.tokens ?? {};
|
||||
const thumbnails = data.data?.thumbnails ?? {};
|
||||
const urls = {};
|
||||
for (const [id, token] of Object.entries(tokens)) {
|
||||
urls[id] = `${STREAM_BASE}/${token}`;
|
||||
// Prefer presigned thumbnail URL (faster, direct); fall back to stream proxy
|
||||
urls[id] = thumbnails[id] ?? `${STREAM_BASE}/${token}`;
|
||||
}
|
||||
setStreamUrls((prev) => ({ ...prev, ...urls }));
|
||||
})
|
||||
@@ -177,7 +183,15 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
||||
|
||||
const handleSelect = (asset) => {
|
||||
setSelected(asset.asset_id);
|
||||
onSelect(asset);
|
||||
// Pass the resolved stream/presigned URL as a second arg so callers
|
||||
// (e.g. badge image picker) can use the authenticated URL directly
|
||||
// rather than falling back to asset.file_url which is a private CDN
|
||||
// key that the browser cannot load without S3 credentials.
|
||||
const resolvedUrl = streamUrls[String(asset.asset_id)]
|
||||
?? asset.thumbnail_url
|
||||
?? asset.file_url
|
||||
?? null;
|
||||
onSelect(asset, resolvedUrl);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* disabled? : boolean
|
||||
* placeholder?: string
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import api from '@/utils/api.util';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -60,7 +60,7 @@ export default function GroupMultiSelect({
|
||||
}, [groupsProp]);
|
||||
|
||||
// ── Position portal dropdown under trigger ────────────────────────────────
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !triggerRef.current) return;
|
||||
|
||||
const reposition = () => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { CalendarDays, Clock, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
/**
|
||||
* DateTimePicker
|
||||
*
|
||||
* value — ISO string | null
|
||||
* onChange — (isoString | null) => void
|
||||
*/
|
||||
export function DateTimePicker({ value, onChange, placeholder = "Pick date & time", disabled }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const dateValue = value ? new Date(value) : undefined;
|
||||
|
||||
const timeStr = dateValue
|
||||
? `${String(dateValue.getHours()).padStart(2, "0")}:${String(dateValue.getMinutes()).padStart(2, "0")}`
|
||||
: "00:00";
|
||||
|
||||
const handleDaySelect = (day) => {
|
||||
if (!day) { onChange(null); return; }
|
||||
const base = dateValue ?? new Date();
|
||||
day.setHours(base.getHours(), base.getMinutes(), 0, 0);
|
||||
onChange(day.toISOString());
|
||||
};
|
||||
|
||||
const handleTimeChange = (e) => {
|
||||
const [h, m] = e.target.value.split(":").map(Number);
|
||||
const base = dateValue ? new Date(dateValue) : new Date();
|
||||
base.setHours(h, m, 0, 0);
|
||||
onChange(base.toISOString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarDays className="mr-2 h-4 w-4 shrink-0" />
|
||||
{dateValue ? format(dateValue, "MMM d, yyyy HH:mm") : placeholder}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dateValue}
|
||||
onSelect={handleDaySelect}
|
||||
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="border-t px-3 py-2.5 flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Label className="text-xs text-muted-foreground shrink-0 w-8">Time</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={timeStr}
|
||||
onChange={handleTimeChange}
|
||||
className="h-8 flex-1"
|
||||
/>
|
||||
{value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => { onChange(null); setOpen(false); }}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ function Progress({
|
||||
{...props}>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="size-full flex-1 bg-primary transition-all"
|
||||
className={cn("size-full flex-1 transition-all", value >= 100 ? "bg-green-500" : "bg-primary")}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user