terry-staging #1
@@ -13,6 +13,6 @@ VITE_ADMIN_ONLY=false
|
|||||||
# Optional admin UI base path. Leave empty to use default app behavior.
|
# Optional admin UI base path. Leave empty to use default app behavior.
|
||||||
VITE_ADMIN_UI_PREFIX=
|
VITE_ADMIN_UI_PREFIX=
|
||||||
|
|
||||||
# Use mock Post data (Telegram-style resource stream) while backend /api/posts
|
# Use mock Post data (Telegram-style resource stream) only when explicitly enabled.
|
||||||
# endpoints are not yet ready. Set to "false" to hit real API.
|
# Default production/staging behavior should hit the real /api/posts API.
|
||||||
VITE_USE_MOCK_POSTS=true
|
VITE_USE_MOCK_POSTS=false
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Create a local `.env` only when needed. Do not commit secrets. See `.env.example
|
|||||||
| `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. |
|
| `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. |
|
||||||
| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. |
|
| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. |
|
||||||
| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. |
|
| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. |
|
||||||
| `VITE_USE_MOCK_POSTS` | Telegram-style resource stream (`/browse`, `/category/:slug`) uses mock posts from `src/mocks/mockPosts.ts` when set to `true` (default while backend `/api/posts` is not yet shipped). Set to `"false"` to hit the real API. See `.unipi/docs/specs/2026-05-25-posts-api-contract.md`. |
|
| `VITE_USE_MOCK_POSTS` | Telegram-style resource stream (`/browse`, `/category/:slug`) uses mock posts from `src/mocks/mockPosts.ts` only when set to `"true"`. Leave unset or set to `"false"` to hit the real `/api/posts` API. See `.unipi/docs/specs/2026-05-25-posts-api-contract.md`. |
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
async function loadApi(apiUrl = "") {
|
async function loadApi(apiUrl = "") {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.stubEnv("VITE_API_URL", apiUrl);
|
vi.stubEnv("VITE_API_URL", apiUrl);
|
||||||
|
vi.stubEnv("VITE_API_PREFIX", "");
|
||||||
return import("./api");
|
return import("./api");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,24 +14,26 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
|
|||||||
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
|
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="relative overflow-hidden rounded-xl bg-black/20">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openLightbox([att], 0, text, post.id)}
|
onClick={() => openLightbox([att], 0, text, post.id)}
|
||||||
className="relative block w-full overflow-hidden rounded-xl max-h-[240px] min-[440px]:max-h-[270px] md:max-h-[320px] lg:max-h-[360px]"
|
className="block w-full"
|
||||||
aria-label={att.filename}
|
aria-label={att.filename}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={att.url}
|
src={att.url}
|
||||||
alt={att.filename}
|
alt={att.filename}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="h-full w-full object-cover"
|
className="block h-auto w-full"
|
||||||
style={{ aspectRatio: ratio }}
|
style={{ aspectRatio: ratio }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{text ? (
|
{text ? (
|
||||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/55 to-transparent px-4 pb-4 pt-16 text-[14px] leading-snug text-neutral-100">
|
||||||
{autolink(text)}
|
<div className="whitespace-pre-wrap break-words">
|
||||||
|
{autolink(text)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Play } from "lucide-react";
|
import { Download, Play } from "lucide-react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { postNoBody } from "../../../api";
|
||||||
import { useI18n } from "../../../i18n";
|
import { useI18n } from "../../../i18n";
|
||||||
import type { Post } from "../../../types/post";
|
import type { Post } from "../../../types/post";
|
||||||
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
||||||
@@ -24,6 +25,8 @@ export function VideoBubble({ post }: { post: Post }) {
|
|||||||
if (!att) return null;
|
if (!att) return null;
|
||||||
const ratio =
|
const ratio =
|
||||||
att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9";
|
att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9";
|
||||||
|
const posterUrl = att.posterUrl ?? att.thumbnailUrl;
|
||||||
|
const previewVideoUrl = att.url.includes("#") ? att.url : `${att.url}#t=0.1`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@@ -48,31 +51,64 @@ export function VideoBubble({ post }: { post: Post }) {
|
|||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<>
|
||||||
type="button"
|
{posterUrl ? (
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setPlaying(true);
|
|
||||||
}}
|
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
|
||||||
aria-label="Play video"
|
|
||||||
>
|
|
||||||
{att.posterUrl ? (
|
|
||||||
<img
|
<img
|
||||||
src={att.posterUrl}
|
src={posterUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
<div className="absolute left-3 top-3 z-10 flex items-center gap-1.5 rounded-full bg-black/55 px-2.5 py-1 text-xs text-white">
|
<video
|
||||||
<span>{formatDuration(att.durationSec)}</span>
|
src={previewVideoUrl}
|
||||||
<span className="opacity-70">·</span>
|
preload="metadata"
|
||||||
<span>{formatBytes(att.sizeBytes)}</span>
|
muted
|
||||||
|
playsInline
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute left-3 top-3 z-10 flex items-center gap-1.5 text-xs text-white">
|
||||||
|
<a
|
||||||
|
href={att.url}
|
||||||
|
download={att.filename}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void postNoBody(
|
||||||
|
`/api/posts/${post.id}/attachments/${att.id}/download`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur transition hover:bg-black/75"
|
||||||
|
aria-label={`Download ${att.filename}`}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
<div className="flex items-center gap-1.5 rounded-full bg-black/55 px-2.5 py-1.5">
|
||||||
|
{formatDuration(att.durationSec) ? (
|
||||||
|
<>
|
||||||
|
<span>{formatDuration(att.durationSec)}</span>
|
||||||
|
<span className="opacity-70">·</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<span>{formatBytes(att.sizeBytes)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10 flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur md:h-14 md:w-14">
|
<button
|
||||||
<Play className="h-5 w-5 translate-x-0.5 fill-white md:h-6 md:w-6" />
|
type="button"
|
||||||
</div>
|
onClick={(e) => {
|
||||||
</button>
|
e.stopPropagation();
|
||||||
|
setPlaying(true);
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
|
aria-label="Play video"
|
||||||
|
>
|
||||||
|
<span className="relative z-10 flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur md:h-14 md:w-14">
|
||||||
|
<Play className="h-5 w-5 translate-x-0.5 fill-white md:h-6 md:w-6" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{text ? (
|
{text ? (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Post, PostListResponse, PostScope } from "../../../types/post";
|
|||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const MOCK_DELAY_MS = 200;
|
const MOCK_DELAY_MS = 200;
|
||||||
|
|
||||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK_POSTS !== "false";
|
const USE_MOCK = import.meta.env.VITE_USE_MOCK_POSTS === "true";
|
||||||
|
|
||||||
export type PostStreamParams = {
|
export type PostStreamParams = {
|
||||||
scope: PostScope;
|
scope: PostScope;
|
||||||
|
|||||||
Reference in New Issue
Block a user