diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5d0850b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.env +src +public +*.config.js +*.config.ts +index.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..da12a44 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# ═══════════════════════════════════════════════════════════════════════════════ +# $APP_NAME — Frontend Environment Variables Template +# Copy to .env and fill in all CHANGE_ME values before building. +# +# Self-hosted setup: +# 1. cp .env.example .env +# 2. Fill in every CHANGE_ME value below +# 3. npm run build (bakes env vars into the dist/ bundle) +# 4. docker build -t $APP_NAME-app . +# (or serve dist/ with any static host — Nginx, Caddy, Vercel, etc.) +# +# NOTE: These are BUILD-TIME variables (VITE_ prefix). Changing them after +# the build has no effect — you must rebuild to pick up new values. +# ═══════════════════════════════════════════════════════════════════════════════ + +# ── API Connection ──────────────────────────────────────────────────────────── +# Point to your backend's public URL — no trailing slash. +# Must match APP_URL / ALLOWED_ORIGINS in the backend .env. +VITE_APP_NAME=starr +VITE_API_URL=https://api.yourdomain.com/api +VITE_APP_URL=https://api.yourdomain.com + +# ── PayPal ──────────────────────────────────────────────────────────────────── +# Client ID is the public key — safe to expose in the browser bundle. +# developer.paypal.com → My Apps & Credentials → Live tab → Client ID +VITE_PAYPAL_CLIENT_ID=CHANGE_ME diff --git a/.gitignore b/.gitignore index 3b0b403..b012a95 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,48 @@ -# Logs -logs +# ── Dependencies ────────────────────────────────────────────────────────────── +node_modules/ + +# ── Environment / Secrets ───────────────────────────────────────────────────── +.env +.env.local +.env.*.local +# Keep .env.example — it is safe and documents required variables + +# ── SSL / TLS / Keys ────────────────────────────────────────────────────────── +*.pem +*.key +*.cert +*.crt +*.p12 +*.pfx + +# ── Build / Output ──────────────────────────────────────────────────────────── +dist/ +dist-ssr/ + +# ── Tauri ───────────────────────────────────────────────────────────────────── +src-tauri/target/ +.tauri/ + +# ── Logs ────────────────────────────────────────────────────────────────────── *.log +logs/ npm-debug.log* -yarn-debug.log* -yarn-error.log* pnpm-debug.log* +yarn-debug.log* lerna-debug.log* -node_modules -dist -dist-ssr -*.local +# ── OS ──────────────────────────────────────────────────────────────────────── +.DS_Store +Thumbs.db +desktop.ini -# Editor directories and files +# ── Editors ─────────────────────────────────────────────────────────────────── .vscode/* !.vscode/extensions.json -.idea -.DS_Store +.idea/ +*.swp +*.swo *.suo *.ntvs* *.njsproj *.sln -*.sw? - -.env \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a450ee2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +# Pre-built image — run `npm run build` first so dist/ exists. +FROM nginx:1.27-alpine + +COPY dist/ /usr/share/nginx/html/ +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index a36934d..1df1e69 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,177 @@ -# React + Vite +# new_starr_app — Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +React + Vite CSR frontend for the STARR LMS platform. -Currently, two official plugins are available: +--- -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +## Page Metadata -## React Compiler +Metadata (`
+ {React.string("Annotation")} +
++ {React.string("Annotation")} +
+Unknown block type.
; } @@ -40,11 +49,14 @@ function BlockContent({ block, onUpdate }) { // ─── Default content per type ───────────────────────────────────────────────── export const DEFAULT_CONTENT = { - "text": { body: "" }, - "image": { asset_id: null, url: "", alt: "" }, + "text": { body: "" }, + "image": { asset_id: null, url: "", alt: "" }, "text-image": { body: "", asset_id: null, url: "", alt: "", image_position: "right" }, - "video": { asset_id: null, url: "", thumbnail_url: "" }, + "video": { asset_id: null, url: "", thumbnail_url: "" }, "text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right" }, + "audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "" }, + "code": { language: "javascript", code: "" }, + "markdown": { body: "" }, }; // ─── List ───────────────────────────────────────────────────────────────────── @@ -69,9 +81,9 @@ export function BlockList({ blocks, onUpdate, onMove, onDelete }) { type={block.type} index={index} total={blocks.length} - onMoveUp={() => onMove(block.id, "up")} - onMoveDown={() => onMove(block.id, "down")} - onDelete={() => onDelete(block.id)} + onMoveUp={() => onMove(block.id, "up")} + onMoveDown={() => onMove(block.id, "down")} + onDelete={() => onDelete(block.id)} >{title}
+ {artist &&{artist}
} +
+ +
Nothing to preview yet.
+ )} +. If the cursor is already inside a
+ // element, unwraps it instead.
+
+ const toggleInlineCode = () => {
+ const sel = window.getSelection();
+ if (!sel || sel.rangeCount === 0) return;
+ const range = sel.getRangeAt(0);
+ const ancestor = range.commonAncestorContainer;
+ const codeParent = (ancestor.nodeType === 3 ? ancestor.parentElement : ancestor)?.closest("code");
+
+ if (codeParent) {
+ const text = document.createTextNode(codeParent.textContent ?? "");
+ codeParent.replaceWith(text);
+ onChange(editorRef.current.innerHTML);
+ } else {
+ const text = sel.toString();
+ if (!text) return;
+ exec("insertHTML", `${text}`);
+ onChange(editorRef.current.innerHTML);
+ }
+ };
+
+ // ── Code block insertion ───────────────────────────────────────────────────
+
+ const insertCodeBlock = () => {
+ editorRef.current?.focus();
+ const sel = window.getSelection();
+ const selectedText = sel?.toString() || "// your code here";
+ exec("insertHTML", `${selectedText}
`);
+ onChange(editorRef.current.innerHTML);
+ };
+
// ── Table insertion ────────────────────────────────────────────────────────
// Builds a with a header row (th) + (rows-1) data rows.
// Each cell is contenteditable (inherited from the editor).
@@ -322,21 +506,21 @@ export function RichTextEditor({ blockId, value, onChange }) {
const GROUPS = [
[
- { cmd: "bold", Icon: Bold, title: "Bold" },
- { cmd: "italic", Icon: Italic, title: "Italic" },
- { cmd: "underline", Icon: Underline, title: "Underline" },
+ { cmd: "bold", Icon: Bold, title: "Bold" },
+ { cmd: "italic", Icon: Italic, title: "Italic" },
+ { cmd: "underline", Icon: Underline, title: "Underline" },
],
[
- { cmd: "justifyLeft", Icon: AlignLeft, title: "Align left" },
- { cmd: "justifyCenter", Icon: AlignCenter, title: "Align center" },
- { cmd: "justifyRight", Icon: AlignRight, title: "Align right" },
- { cmd: "justifyFull", Icon: AlignJustify, title: "Justify" },
+ { cmd: "justifyLeft", Icon: AlignLeft, title: "Align left" },
+ { cmd: "justifyCenter", Icon: AlignCenter, title: "Align center" },
+ { cmd: "justifyRight", Icon: AlignRight, title: "Align right" },
+ { cmd: "justifyFull", Icon: AlignJustify, title: "Justify" },
],
[
- { cmd: "insertUnorderedList", Icon: List, title: "Bullet list" },
- { cmd: "insertOrderedList", Icon: ListOrdered, title: "Numbered list" },
- { cmd: "indent", Icon: Indent, title: "Indent" },
- { cmd: "outdent", Icon: Outdent, title: "Outdent" },
+ { cmd: "insertUnorderedList", Icon: List, title: "Bullet list" },
+ { cmd: "insertOrderedList", Icon: ListOrdered, title: "Numbered list" },
+ { cmd: "indent", Icon: Indent, title: "Indent" },
+ { cmd: "outdent", Icon: Outdent, title: "Outdent" },
],
];
@@ -408,6 +592,24 @@ export function RichTextEditor({ blockId, value, onChange }) {
+
+
+
+ {/* Inline code */}
+ { e.preventDefault(); toggleInlineCode(); }}
+ >
+
+
+
+ {/* Code block */}
+ { e.preventDefault(); insertCodeBlock(); }}
+ >
+
+
{/* ── Editable area ── */}
@@ -431,27 +633,37 @@ export function RichTextEditor({ blockId, value, onChange }) {
)}
{/* Document asset picker */}
-
+ {!readOnly && (
+
+ )}
>
);
}
// ─── TextBlock ────────────────────────────────────────────────────────────────
-export function TextBlock({ content, onUpdate, blockId }) {
- return (
-
-
- onUpdate({ ...content, body: html })}
- />
-
- );
+export function TextBlock({ content, onUpdate, blockId, readOnly = false }) {
+ if (readOnly) {
+ return (
+
+ );
+ }
+ return (
+
+
+ onUpdate({ ...content, body: html })}
+ />
+
+ );
}
\ No newline at end of file
diff --git a/src/components/generic/Blocks/TextImageBlock.jsx b/src/components/generic/Blocks/Admin/TextImageBlock.jsx
similarity index 87%
rename from src/components/generic/Blocks/TextImageBlock.jsx
rename to src/components/generic/Blocks/Admin/TextImageBlock.jsx
index b2230f8..5082193 100644
--- a/src/components/generic/Blocks/TextImageBlock.jsx
+++ b/src/components/generic/Blocks/Admin/TextImageBlock.jsx
@@ -12,9 +12,9 @@ import {
SelectValue,
} from "@/components/ui/select";
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
-import { AssetPickerSheet } from "../AssetPickerSheet";
+import { AssetPickerSheet } from "../../AssetPickerSheet";
-export function TextImageBlock({ content, onUpdate, blockId }) {
+export function TextImageBlock({ content, onUpdate, blockId, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false);
return (
@@ -92,16 +92,18 @@ export function TextImageBlock({ content, onUpdate, blockId }) {
- onUpdate({
- ...content,
- asset_id: asset.asset_id,
- url: asset.file_url,
- })}
- />
+ {!readOnly && (
+ onUpdate({
+ ...content,
+ asset_id: asset.asset_id,
+ url: asset.file_url,
+ })}
+ />
+ )}
);
}
\ No newline at end of file
diff --git a/src/components/generic/Blocks/TextVideoBlock.jsx b/src/components/generic/Blocks/Admin/TextVideoBlock.jsx
similarity index 87%
rename from src/components/generic/Blocks/TextVideoBlock.jsx
rename to src/components/generic/Blocks/Admin/TextVideoBlock.jsx
index b86cd24..f83257a 100644
--- a/src/components/generic/Blocks/TextVideoBlock.jsx
+++ b/src/components/generic/Blocks/Admin/TextVideoBlock.jsx
@@ -11,9 +11,9 @@ import {
SelectValue,
} from "@/components/ui/select";
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
-import { AssetPickerSheet } from "../AssetPickerSheet";
+import { AssetPickerSheet } from "../../AssetPickerSheet";
-export function TextVideoBlock({ content, onUpdate, blockId }) {
+export function TextVideoBlock({ content, onUpdate, blockId, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false);
const thumb = content.thumbnail_url ?? null;
@@ -93,17 +93,19 @@ export function TextVideoBlock({ content, onUpdate, blockId }) {
- onUpdate({
- ...content,
- asset_id: asset.asset_id,
- url: asset.file_url,
- thumbnail_url: asset.thumbnail_url ?? null,
- })}
- />
+ {!readOnly && (
+ onUpdate({
+ ...content,
+ asset_id: asset.asset_id,
+ url: asset.file_url,
+ thumbnail_url: asset.thumbnail_url ?? null,
+ })}
+ />
+ )}
);
}
\ No newline at end of file
diff --git a/src/components/generic/Blocks/Admin/VideoBlock.jsx b/src/components/generic/Blocks/Admin/VideoBlock.jsx
new file mode 100644
index 0000000..f0e2d89
--- /dev/null
+++ b/src/components/generic/Blocks/Admin/VideoBlock.jsx
@@ -0,0 +1,280 @@
+import { useRef, useState, useEffect, useCallback } from "react";
+import {
+ Play,
+ Pause,
+ SkipBack,
+ Volume2,
+ VolumeX,
+ Maximize2,
+ VideoIcon,
+} from "lucide-react";
+import { Label } from "@/components/ui/label";
+import { AssetPickerSheet } from "../../AssetPickerSheet";
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+const fmtTime = (s) => {
+ if (!s || isNaN(s)) return "0:00";
+ const m = Math.floor(s / 60);
+ const sec = Math.floor(s % 60);
+ return `${m}:${sec < 10 ? "0" : ""}${sec}`;
+};
+
+// ─── VideoBlock (Admin) ───────────────────────────────────────────────────────
+
+export function VideoBlock({ content, onUpdate, readOnly = false }) {
+ const [pickerOpen, setPickerOpen] = useState(false);
+
+ const vidRef = useRef(null);
+ const [playing, setPlaying] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [totalDuration, setTotalDuration] = useState(0);
+ const [muted, setMuted] = useState(false);
+ const [volume, setVolume] = useState(1);
+ const [overlayVisible,setOverlayVisible]= useState(true);
+
+ // Reset player when video changes
+ useEffect(() => {
+ setPlaying(false);
+ setProgress(0);
+ setCurrentTime(0);
+ setTotalDuration(0);
+ setOverlayVisible(true);
+ }, [content.url]);
+
+ // ── Video event listeners ─────────────────────────────────────────────────
+
+ useEffect(() => {
+ const v = vidRef.current;
+ if (!v) return;
+
+ const onTimeUpdate = () => {
+ setCurrentTime(v.currentTime);
+ if (v.duration) setProgress((v.currentTime / v.duration) * 100);
+ };
+ const onLoaded = () => setTotalDuration(v.duration);
+ const onEnded = () => { setPlaying(false); setOverlayVisible(true); };
+
+ v.addEventListener("timeupdate", onTimeUpdate);
+ v.addEventListener("loadedmetadata", onLoaded);
+ v.addEventListener("ended", onEnded);
+
+ return () => {
+ v.removeEventListener("timeupdate", onTimeUpdate);
+ v.removeEventListener("loadedmetadata", onLoaded);
+ v.removeEventListener("ended", onEnded);
+ };
+ }, [content.url]);
+
+ // ── Controls ──────────────────────────────────────────────────────────────
+
+ const togglePlay = useCallback(() => {
+ const v = vidRef.current;
+ if (!v) return;
+ if (v.paused) { v.play(); setPlaying(true); setOverlayVisible(false); }
+ else { v.pause(); setPlaying(false); setOverlayVisible(true); }
+ }, []);
+
+ const restart = () => {
+ const v = vidRef.current;
+ if (!v) return;
+ v.currentTime = 0;
+ v.pause();
+ setPlaying(false);
+ setOverlayVisible(true);
+ };
+
+ const handleSeek = (e) => {
+ const v = vidRef.current;
+ if (!v || !v.duration) return;
+ v.currentTime = (parseFloat(e.target.value) / 100) * v.duration;
+ };
+
+ const handleVolumeChange = (e) => {
+ const val = parseFloat(e.target.value);
+ setVolume(val);
+ if (vidRef.current) { vidRef.current.volume = val; vidRef.current.muted = val === 0; }
+ setMuted(val === 0);
+ };
+
+ const toggleMute = () => {
+ const v = vidRef.current;
+ if (!v) return;
+ v.muted = !v.muted;
+ setMuted(v.muted);
+ };
+
+ const toggleFullscreen = () => {
+ const el = document.getElementById("video-block-wrap");
+ if (!el) return;
+ if (document.fullscreenElement) document.exitFullscreen();
+ else el.requestFullscreen?.();
+ };
+
+ // ── Asset picker handler ──────────────────────────────────────────────────
+ //
+ // Saves all metadata needed by the client VideoBlock at render time.
+ // Clean object — no ...content spread so stale data never carries over.
+ //
+ // Fields saved:
+ // asset_id — used by client for the secure token flow
+ // url — used by admin player (direct src); ignored by client for S3
+ // thumbnail_url — poster image for the video player
+ // title — display name (shown in any title-aware blocks)
+ // tag — file extension badge e.g. "MP4"
+ //
+ const handleSelect = (asset) => {
+ onUpdate({
+ asset_id: asset.asset_id,
+ url: asset.file_url,
+ thumbnail_url: asset.thumbnail_url ?? null,
+ title: asset.display_name,
+ tag: asset.extension?.toUpperCase() ?? "",
+ });
+ setPlaying(false);
+ setProgress(0);
+ setCurrentTime(0);
+ setTotalDuration(0);
+ setOverlayVisible(true);
+ };
+
+ // ─────────────────────────────────────────────────────────────────────────
+
+ return (
+
+ {!readOnly && }
+
+ {content.url ? (
+
+
+ {/* ── Video area ── */}
+
+
+
+ {/* Play/pause overlay */}
+
+
+
+
+ {/* Change video hover hint */}
+ {!readOnly && (
+
+
+
+ )}
+
+
+ {/* ── Player controls ── */}
+
+
+ {/* Progress bar */}
+
+
+
+
+
+ {/* Button row */}
+
+
+
+
+ {fmtTime(currentTime)} / {fmtTime(totalDuration)}
+
+
+
+
+
+
+
+
+
+
+
+ {/* ── Change video footer (admin only) ── */}
+ {!readOnly && (
+
+
+
+ )}
+
+ ) : (
+
+ )}
+
+ {!readOnly && (
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/Blocks/Client/Advertisements/Banner.jsx b/src/components/generic/Blocks/Client/Advertisements/Banner.jsx
new file mode 100644
index 0000000..8435d9a
--- /dev/null
+++ b/src/components/generic/Blocks/Client/Advertisements/Banner.jsx
@@ -0,0 +1,60 @@
+// components/blocks/Banner.jsx
+
+import { Megaphone } from "lucide-react";
+import { Skeleton } from "@/components/ui/skeleton";
+
+// Height per size — controls the strip's visual weight, not its width (always full-width).
+const SIZE_HEIGHT = {
+ sm: "h-20",
+ md: "h-32",
+ lg: "h-48",
+};
+
+// ── Banner ───────────────────────────────────────────────────────────────────
+/**
+ * Generic banner advertisement block.
+ * Full-width horizontal strip, image-led with optional headline overlay.
+ * Click anywhere on the banner triggers the first available CTA (or just tracks
+ * the click if no CTA exists) — banners don't carry their own button row.
+ *
+ * Props:
+ * ad — advertisement object { headline, ctas, image, image_url, advertisement_id }
+ * size — "sm" | "md" | "lg" (default "md")
+ * onCtaClick — (ad, cta) => void, called on click. cta may be undefined if the ad has none.
+ */
+export function Banner({ ad, size, onCtaClick }) {
+ if (!ad) return null;
+
+ const imageSrc = ad.image?.file_url || ad.image_url || null;
+ const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
+ const resolvedSize = ad.size || size || "md";
+ const heightClass = SIZE_HEIGHT[resolvedSize] ?? SIZE_HEIGHT.md;
+
+ const handleClick = () => onCtaClick?.(ad, ctas[0]);
+
+ return (
+
+ );
+}
+
+// ── BannerSkeleton ───────────────────────────────────────────────────────────
+
+export function BannerSkeleton({ size = "md" }) {
+ return ;
+}
\ No newline at end of file
diff --git a/src/components/generic/Blocks/Client/Advertisements/Hero.jsx b/src/components/generic/Blocks/Client/Advertisements/Hero.jsx
new file mode 100644
index 0000000..9a31dd4
--- /dev/null
+++ b/src/components/generic/Blocks/Client/Advertisements/Hero.jsx
@@ -0,0 +1,85 @@
+// components/blocks/Hero.jsx
+
+import { Megaphone } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+
+// ── Hero ─────────────────────────────────────────────────────────────────────
+/**
+ * Generic hero advertisement block.
+ * Two-column layout: badge/headline/description/CTAs on the left, image on the right.
+ * Renders null when no ad is provided — callers should not fall back to placeholder copy.
+ *
+ * Props:
+ * ad — advertisement object { badge_label, headline, description, ctas, image, image_url, advertisement_id }
+ * onCtaClick — (ad, cta) => void, called when any CTA button is clicked
+ */
+export function Hero({ ad, onCtaClick }) {
+ if (!ad) return null;
+
+ const imageSrc = ad.image?.file_url || ad.image_url || null;
+ const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
+
+ return (
+
+
+ {ad.badge_label && (
+
+ {ad.badge_label}
+
+ )}
+ {ad.headline && (
+
+ {ad.headline}
+
+ )}
+ {ad.description && (
+
+ {ad.description}
+
+ )}
+ {ctas.length > 0 && (
+
+ {ctas.map((cta, i) => (
+
+ ))}
+
+ )}
+
+
+ {imageSrc ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+// ── HeroSkeleton ─────────────────────────────────────────────────────────────
+
+export function HeroSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/Blocks/Client/Advertisements/Popup.jsx b/src/components/generic/Blocks/Client/Advertisements/Popup.jsx
new file mode 100644
index 0000000..dd8aa3d
--- /dev/null
+++ b/src/components/generic/Blocks/Client/Advertisements/Popup.jsx
@@ -0,0 +1,54 @@
+// components/blocks/Popup.jsx
+
+import { Button } from "@/components/ui/button";
+import ResponsiveModal from "@/components/generic/ResponsiveModal";
+
+// ── Popup ────────────────────────────────────────────────────────────────────
+/**
+ * Generic popup advertisement block.
+ * Modal-style placement shown on page load — wraps ResponsiveModal so it gets
+ * dialog/drawer behavior for free. Caller owns the `open` state (typically set
+ * to true once an active popup ad resolves from the API).
+ *
+ * Props:
+ * ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
+ * open — boolean, modal visibility
+ * onOpenChange — (open: boolean) => void
+ * onCtaClick — (ad, cta) => void, called when a footer CTA button is clicked
+ */
+export function Popup({ ad, open, onOpenChange, onCtaClick }) {
+ if (!ad) return null;
+
+ const imageSrc = ad.image?.file_url || ad.image_url || null;
+ const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
+
+ return (
+ 0 ? (
+ <>
+ {ctas.map((cta, i) => (
+
+ ))}
+ >
+ ) : undefined
+ }
+ >
+ {imageSrc && (
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/Blocks/Client/Advertisements/Sidebar.jsx b/src/components/generic/Blocks/Client/Advertisements/Sidebar.jsx
new file mode 100644
index 0000000..984a850
--- /dev/null
+++ b/src/components/generic/Blocks/Client/Advertisements/Sidebar.jsx
@@ -0,0 +1,77 @@
+// components/blocks/Sidebar.jsx
+
+import { Megaphone } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+
+// ── Sidebar ──────────────────────────────────────────────────────────────────
+/**
+ * Generic sidebar advertisement block.
+ * Compact vertical card — image on top, optional short headline/description and
+ * a single CTA below. Meant to sit in a narrow column (sidebars, rail layouts),
+ * not stretch full-width like Hero/Banner.
+ *
+ * Props:
+ * ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
+ * onCtaClick — (ad, cta) => void, called when the CTA button (or card, if no CTA) is clicked
+ */
+export function Sidebar({ ad, onCtaClick }) {
+ if (!ad) return null;
+
+ const imageSrc = ad.image?.file_url || ad.image_url || null;
+ const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
+ const primaryCta = ctas[0];
+
+ const handleCardClick = () => {
+ if (!primaryCta) onCtaClick?.(ad, undefined);
+ };
+
+ return (
+
+
+ {imageSrc ? (
+
+ ) : (
+
+ )}
+
+
+ {(ad.headline || ad.description || primaryCta) && (
+
+ {ad.headline && {ad.headline}
}
+ {ad.description && {ad.description}
}
+ {primaryCta && (
+
+ )}
+
+ )}
+
+ );
+}
+
+// ── SidebarSkeleton ──────────────────────────────────────────────────────────
+
+export function SidebarSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/generic/Blocks/Client/AudioBlock.jsx b/src/components/generic/Blocks/Client/AudioBlock.jsx
new file mode 100644
index 0000000..3fb56b1
--- /dev/null
+++ b/src/components/generic/Blocks/Client/AudioBlock.jsx
@@ -0,0 +1,329 @@
+import { useRef, useState, useEffect, useCallback } from "react";
+import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lucide-react";
+import api from "@/utils/api.util";
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+const fmtTime = (s) => {
+ if (!s || isNaN(s)) return "0:00";
+ const m = Math.floor(s / 60);
+ const sec = Math.floor(s % 60);
+ return `${m}:${sec < 10 ? "0" : ""}${sec}`;
+};
+
+const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
+
+const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
+
+// ─── AudioBlock (Client — secure) ────────────────────────────────────────────
+//
+// S3/Garage:
+// 1. POST /client/media/token → get JWT token
+// 2. fetch(streamUrl, { credentials: "include" }) → get raw bytes
+// 3. URL.createObjectURL(blob) → blob:http://... URL
+// 4.