mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
wip: QAS environment banner + upload-limit messaging (pre-monorepo checkpoint)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
2de6e58488
commit
459af9d46a
@@ -20,6 +20,11 @@ VITE_APP_NAME=starr
|
||||
VITE_API_URL=https://api.yourdomain.com/api
|
||||
VITE_APP_URL=https://api.yourdomain.com
|
||||
|
||||
# ── Environment ───────────────────────────────────────────────────────────────
|
||||
# "production" (droplet) or "qas" (home-hosted, behind Cloudflare Tunnel).
|
||||
# Set to "qas" only on the QAS Preview build in Vercel's project settings.
|
||||
VITE_APP_ENV=production
|
||||
|
||||
# ── PayPal ────────────────────────────────────────────────────────────────────
|
||||
# Client ID is the public key — safe to expose in the browser bundle.
|
||||
# developer.paypal.com → My Apps & Credentials → Live tab → Client ID
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
// Hardcoded (not DB-driven) — flip to false if it should reappear on every
|
||||
// reload instead of staying dismissed once closed.
|
||||
const PERSIST_DISMISSAL = true;
|
||||
const DISMISS_KEY = "qas_env_banner_dismissed";
|
||||
|
||||
export default function EnvironmentBanner() {
|
||||
const [dismissed, setDismissed] = useState(
|
||||
() => PERSIST_DISMISSAL && localStorage.getItem(DISMISS_KEY) === "true"
|
||||
);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (PERSIST_DISMISSAL) localStorage.setItem(DISMISS_KEY, "true");
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div role="status" className="w-full shadow-sm px-4 md:px-6 bg-amber-400">
|
||||
<div className="relative w-full rounded-none py-2 flex items-center justify-center px-10">
|
||||
<p className="text-sm font-semibold leading-snug truncate text-amber-950">
|
||||
Development / Staging (QAS) environment — not production
|
||||
</p>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleDismiss}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
e.preventDefault();
|
||||
handleDismiss();
|
||||
}}
|
||||
aria-label="Dismiss environment banner"
|
||||
title="Dismiss"
|
||||
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity text-amber-950"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import { cn } from "@/lib/utils"
|
||||
import UserMenu from "@/components/generic/UserMenu"
|
||||
import NotificationBell from "@/components/generic/NotificationBell"
|
||||
import AdminStickyAnnouncementBar from "@/components/generic/AdminStickyAnnouncementBar"
|
||||
import EnvironmentBanner from "@/components/generic/EnvironmentBanner"
|
||||
import { isQasEnv } from "@/utils/environment.util"
|
||||
import UploadProgressToast from "@/components/generic/UploadProgressToast"
|
||||
import { ROLE_CONFIG } from "@/data/profile.data"
|
||||
|
||||
@@ -50,6 +52,7 @@ const AdminLayout = () => {
|
||||
{/* AdminProvider wraps header + body so UserMenu can access ProfileProvider */}
|
||||
<AdminProvider>
|
||||
<div ref={headerRef} className={cn('fixed top-0 z-50 w-full bg-background border-b')} >
|
||||
{isQasEnv && <EnvironmentBanner />}
|
||||
<AdminStickyAnnouncementBar />
|
||||
<div className="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useUploadQueue } from "@/contexts/UploadQueueContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { MAX_ASSET_FILE_SIZE_SINGLE, MAX_ASSET_FILE_SIZE_SINGLE_LABEL } from "@/utils/assetUpload.util";
|
||||
import { formatFileSize } from "@/utils/format.util";
|
||||
import { isQasEnv, QAS_UPLOAD_LIMIT_MB } from "@/utils/environment.util";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -239,6 +240,11 @@ export default function AddAsset() {
|
||||
error={errors._file?.message}
|
||||
/>
|
||||
<FieldError message={errors._file?.message} />
|
||||
{isQasEnv && (
|
||||
<p className="text-xs text-amber-600">
|
||||
Self-hosted staging environment — uploads over {QAS_UPLOAD_LIMIT_MB}MB may fail (Cloudflare limit).
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
to="/admin/assets/add/bulk"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { useUploadQueue } from "@/contexts/UploadQueueContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { formatFileSize } from "@/utils/format.util";
|
||||
import { isQasEnv, QAS_UPLOAD_LIMIT_MB } from "@/utils/environment.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
@@ -194,6 +195,11 @@ export default function AddAssetsBulk() {
|
||||
|
||||
{/* ── Drop zone ── */}
|
||||
<DropZone onFiles={handleFiles} />
|
||||
{isQasEnv && (
|
||||
<p className="text-xs text-amber-600">
|
||||
Self-hosted staging environment — uploads over {QAS_UPLOAD_LIMIT_MB}MB may fail (Cloudflare limit).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── File list ── */}
|
||||
{total > 0 && (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { isQasEnv, QAS_UPLOAD_LIMIT_MB } from "@/utils/environment.util";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function formatBytes(b) {
|
||||
@@ -271,6 +272,11 @@ const FileUpload = ({
|
||||
<p className="text-sm font-medium">Drop files here or click to browse</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Upload your work to submit with this task</p>
|
||||
<p className="text-sm mt-2">{derivedHint}</p>
|
||||
{isQasEnv && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
Self-hosted staging environment — uploads over {QAS_UPLOAD_LIMIT_MB}MB may fail (Cloudflare limit).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -303,6 +309,11 @@ const FileUpload = ({
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-2">{derivedHint}</p>
|
||||
{isQasEnv && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
Self-hosted staging environment — uploads over {QAS_UPLOAD_LIMIT_MB}MB may fail (Cloudflare limit).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ import { AVATAR_COLORS } from "@/data/profile.data"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
|
||||
import StickyAnnouncementBar from "@/components/generic/StickyAnnouncementBar"
|
||||
import EnvironmentBanner from "@/components/generic/EnvironmentBanner"
|
||||
import { isQasEnv } from "@/utils/environment.util"
|
||||
import { QRCodeCanvas } from "qrcode.react"
|
||||
|
||||
// ─── Refer / Invite dialog ────────────────────────────────────────────────────
|
||||
@@ -443,6 +445,7 @@ const ClientLayout = () => {
|
||||
<ClientProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header ref={headerRef} className="fixed top-0 left-0 right-0 z-50 flex flex-col">
|
||||
{isQasEnv && <EnvironmentBanner />}
|
||||
<StickyAnnouncementBar />
|
||||
<ClientNav />
|
||||
</header>
|
||||
|
||||
@@ -95,8 +95,7 @@ const QuizGateList = ({ requiredQuizzes, units, onQuizClick, onItemClick }) => (
|
||||
onQuizClick({ unit: units.find((u) => u.unit_id === q.unitId), quiz: { quiz_id: q.quizId } });
|
||||
onItemClick?.();
|
||||
}}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
|
||||
q.has_passed
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${q.has_passed
|
||||
? "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800"
|
||||
: "border-border hover:bg-muted cursor-pointer"
|
||||
}`}
|
||||
@@ -228,8 +227,7 @@ const SidebarContent = ({
|
||||
return (
|
||||
<li
|
||||
onClick={() => onQuizClick({ unit, quiz: unit.quiz })}
|
||||
className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${
|
||||
selectedQuizId === unit.quiz.quiz_id
|
||||
className={`flex items-center gap-2 pl-6 pr-3 py-1.5 text-sm rounded-md cursor-pointer transition-colors ${selectedQuizId === unit.quiz.quiz_id
|
||||
? "bg-muted-foreground/10 text-foreground font-medium"
|
||||
: unit.quiz.has_passed
|
||||
? "text-emerald-600 dark:text-emerald-400 hover:bg-muted-foreground/10"
|
||||
@@ -335,6 +333,7 @@ const UnitList = () => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [taskModeOpen, setTaskModeOpen] = useState(false);
|
||||
|
||||
const resetCompletion = () => setSelectedCompletion(false);
|
||||
|
||||
@@ -973,20 +972,21 @@ const UnitList = () => {
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
|
||||
{/* ── Task-mode banner ─────────────────────────────────────────── */}
|
||||
{taskCtx?.has_task && (
|
||||
<div className={`fixed xs:top-[124px] lg:top-[112px] left-0 right-0 z-30 flex items-center gap-2 text-white text-xs font-medium px-4 py-1.5 shadow-sm transition-colors ${
|
||||
course?.is_completed ? 'bg-green-600' : 'bg-blue-600'
|
||||
}`}>
|
||||
<ListChecks className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{course?.is_completed
|
||||
? 'Course complete — tracking finished'
|
||||
: 'Task mode — reading progress is being tracked automatically'
|
||||
}
|
||||
</span>
|
||||
{/* ── "Task mode" info dialog ── */}
|
||||
<ResponsiveModal
|
||||
open={taskModeOpen}
|
||||
onOpenChange={setTaskModeOpen}
|
||||
title="Task mode"
|
||||
description=""
|
||||
footer={<Button onClick={() => setTaskModeOpen(false)}>Got it</Button>}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
This course is part of one of your assigned <strong className="text-foreground">tasks</strong>.
|
||||
Your reading and completion progress here is being tracked automatically — no need to do anything extra.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</ResponsiveModal>
|
||||
|
||||
{/* ── Up next after passing a quiz ── */}
|
||||
{selectedQuizId && quiz?.has_passed && !quizSessionActive && nextContent && (
|
||||
@@ -1019,9 +1019,29 @@ const UnitList = () => {
|
||||
<AppBreadcrumb items={breadcrumbItemsMobile} />
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-2 shrink-0">
|
||||
{taskCtx?.has_task && (
|
||||
course?.is_completed ? (
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400 shrink-0">
|
||||
<CheckCheck className="size-4" />
|
||||
Task complete
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-1.5 text-blue-600 dark:text-blue-400 shrink-0"
|
||||
onClick={() => setTaskModeOpen(true)}
|
||||
>
|
||||
<ListChecks className="size-4" />
|
||||
Task mode
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{!selectedQuizId && !selectedAssessment && !selectedCompletion && lesson && (
|
||||
isProgressCompleted(lesson.uuid) ? (
|
||||
<div className="hidden lg:flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400 shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400 shrink-0">
|
||||
<CheckCheck className="size-4" />
|
||||
Done
|
||||
</div>
|
||||
@@ -1029,7 +1049,7 @@ const UnitList = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden lg:flex items-center gap-1.5 text-muted-foreground shrink-0"
|
||||
className="flex items-center gap-1.5 text-muted-foreground shrink-0"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
>
|
||||
<HelpCircle className="size-4" />
|
||||
@@ -1037,6 +1057,7 @@ const UnitList = () => {
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
@@ -1086,7 +1107,7 @@ const UnitList = () => {
|
||||
</div>
|
||||
|
||||
{/* ── Desktop sidebar: rail + collapsible content panel (desktop only) ── */}
|
||||
<div className={`hidden lg:flex flex-row fixed ${taskCtx?.has_task ? "top-[140px]" : "top-[112px]"} bottom-0 left-0 z-30 bg-muted border-r`}>
|
||||
<div className="hidden lg:flex flex-row fixed top-[112px] bottom-0 left-0 z-30 bg-muted border-r">
|
||||
{/* Rail — always visible */}
|
||||
<div className="w-14 shrink-0 flex flex-col items-center pt-3">
|
||||
<Button
|
||||
@@ -1130,7 +1151,7 @@ const UnitList = () => {
|
||||
</div>
|
||||
|
||||
{/* ── Main content ── */}
|
||||
<div className={`${taskCtx?.has_task ? "xs:mt-42 lg:mt-36" : "mt-32"} ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
|
||||
<div className={`mt-32 ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
|
||||
<div className="relative w-full h-full mx-auto max-w-2xl">
|
||||
{selectedCompletion ? (
|
||||
<CourseCompleteBlock course={course} />
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const isQasEnv = import.meta.env.VITE_APP_ENV === "qas";
|
||||
|
||||
export const QAS_UPLOAD_LIMIT_MB = 100;
|
||||
Reference in New Issue
Block a user