feat: initial

This commit is contained in:
2026-06-03 03:08:57 +03:30
parent 3d9585ac5c
commit 6fa30eb29a
45 changed files with 7053 additions and 86 deletions
+133
View File
@@ -0,0 +1,133 @@
// Thin fetch wrapper for the meditation API. The app is a static SPA, so every
// call runs in the browser and carries the bearer token from localStorage.
import { MEDITATION_BASE_URL, TOKEN_STORAGE_KEY } from "./config";
export class ApiError extends Error {
status: number;
data: unknown;
constructor(message: string, status: number, data: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.data = data;
}
}
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(TOKEN_STORAGE_KEY);
}
export function setToken(token: string) {
if (typeof window === "undefined") return;
window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
}
export function clearToken() {
if (typeof window === "undefined") return;
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
}
type Query = Record<string, string | number | boolean | undefined | null>;
interface RequestOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
// Plain object -> JSON body. FormData -> multipart (browser sets boundary).
body?: unknown;
query?: Query;
// Override/extend the base url (e.g. the approagency host for login).
baseUrl?: string;
// Skip attaching the bearer token (used by the login calls).
auth?: boolean;
}
function buildUrl(path: string, query?: Query, baseUrl = MEDITATION_BASE_URL) {
const url = new URL(
`${baseUrl}${path.startsWith("/") ? path : `/${path}`}`,
);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
}
return url.toString();
}
export async function apiFetch<T = unknown>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const { method = "GET", body, query, baseUrl, auth = true } = options;
const headers: Record<string, string> = { Accept: "application/json" };
if (auth) {
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
}
let payload: BodyInit | undefined;
if (body instanceof FormData) {
payload = body; // browser sets multipart Content-Type + boundary
} else if (body !== undefined) {
headers["Content-Type"] = "application/json";
payload = JSON.stringify(body);
}
const res = await fetch(buildUrl(path, query, baseUrl), {
method,
headers,
body: payload,
});
const text = await res.text();
let data: unknown = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!res.ok) {
const message =
(data && typeof data === "object" && "message" in data
? String((data as { message: unknown }).message)
: null) ?? `خطای ارتباط با سرور (${res.status})`;
throw new ApiError(message, res.status, data);
}
return data as T;
}
// Many Laravel resource endpoints wrap the payload as { data: ... }. Unwrap it
// when present so callers always get the raw value.
export function unwrap<T = unknown>(res: unknown): T {
if (res && typeof res === "object" && "data" in res) {
return (res as { data: T }).data;
}
return res as T;
}
// Convenience builder for multipart bodies. Skips undefined/null and expands
// arrays to repeated `key[]` entries the way Laravel expects.
export function toFormData(fields: Record<string, unknown>): FormData {
const fd = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (value === undefined || value === null || value === "") continue;
if (value instanceof File || value instanceof Blob) {
fd.append(key, value);
} else if (Array.isArray(value)) {
for (const item of value) fd.append(`${key}[]`, String(item));
} else if (typeof value === "boolean") {
fd.append(key, value ? "1" : "0");
} else {
fd.append(key, String(value));
}
}
return fd;
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
// Two-step login flow + auth state, exposed through React context.
//
// Step 1: POST {approagency}/auth/login (auth, password, package_name) -> approoToken
// Step 2: POST {meditation}/auth/login-with-approo-v2?token=approoToken&package_name=...
// (multipart: token, package_name) -> meditation bearer token
//
// The meditation token is persisted in localStorage and attached to every
// subsequent request by lib/api.ts.
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { APPRO_BASE_URL, MEDITATION_BASE_URL, PACKAGE_NAME } from "./config";
import {
ApiError,
apiFetch,
clearToken,
getToken,
setToken,
toFormData,
} from "./api";
interface AuthState {
token: string | null;
ready: boolean; // hydrated from localStorage yet?
login: (identifier: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthState | null>(null);
async function approAgencyLogin(
identifier: string,
password: string,
): Promise<string> {
const res = await apiFetch<{ token?: string }>("/auth/login", {
method: "POST",
baseUrl: APPRO_BASE_URL,
auth: false,
body: toFormData({
auth: identifier,
password,
package_name: PACKAGE_NAME,
}),
});
if (!res?.token) {
throw new ApiError("نام کاربری یا رمز عبور نادرست است.", 401, res);
}
return res.token;
}
async function meditationLogin(approoToken: string): Promise<string> {
const res = await apiFetch<{ token?: string }>(
"/auth/login-with-approo-v2",
{
method: "POST",
baseUrl: MEDITATION_BASE_URL,
auth: false,
query: { token: approoToken, package_name: PACKAGE_NAME },
body: toFormData({ token: approoToken, package_name: PACKAGE_NAME }),
},
);
if (!res?.token) {
throw new ApiError("ورود به سرویس مدیتیشن ناموفق بود.", 401, res);
}
return res.token;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setTokenState] = useState<string | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
setTokenState(getToken());
setReady(true);
}, []);
const login = useCallback(async (identifier: string, password: string) => {
const approoToken = await approAgencyLogin(identifier, password);
const medToken = await meditationLogin(approoToken);
setToken(medToken);
setTokenState(medToken);
}, []);
const logout = useCallback(() => {
clearToken();
setTokenState(null);
}, []);
return (
<AuthContext.Provider value={{ token, ready, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
return ctx;
}
+23
View File
@@ -0,0 +1,23 @@
// Central runtime configuration. Both URLs are public (the app is a static SPA
// that talks to these APIs directly from the browser), so NEXT_PUBLIC_ envs are
// used as optional overrides with sensible defaults baked in.
export const APPRO_BASE_URL =
process.env.NEXT_PUBLIC_APPRO_BASE_URL ?? "https://api.approagency.ir/api";
export const MEDITATION_BASE_URL =
process.env.NEXT_PUBLIC_MEDITATION_BASE_URL ??
"https://meditation.approagency.ir/api";
export const PACKAGE_NAME =
process.env.NEXT_PUBLIC_PACKAGE_NAME ?? "com.approagency.meditation";
// Origin that serves uploaded files (audio/video/images). Defaults to the
// meditation host without the trailing `/api`, so relative storage paths like
// `storage/sounds/x.mp3` resolve to a full URL.
export const ASSET_BASE_URL =
process.env.NEXT_PUBLIC_ASSET_BASE_URL ??
MEDITATION_BASE_URL.replace(/\/api\/?$/, "");
// localStorage key for the meditation bearer token.
export const TOKEN_STORAGE_KEY = "meditation_admin_token";
+76
View File
@@ -0,0 +1,76 @@
// Helpers for resolving uploaded-asset URLs out of loosely-typed API records.
// Response field names vary across endpoints, so we probe a list of common keys
// and turn relative storage paths into absolute URLs.
import { ASSET_BASE_URL } from "./config";
// Turn a possibly-relative path into an absolute URL. The API serves uploads
// from `/storage/...`, while DB fields like `file_path` hold paths relative to
// that (e.g. "music/x.mp3"), so we add the `storage/` prefix when missing.
export function assetUrl(path?: string | null): string | null {
if (!path) return null;
if (/^(https?:|data:|blob:)/i.test(path)) return path;
let p = String(path).replace(/^\/+/, "");
if (!p.startsWith("storage/")) p = `storage/${p}`;
return `${ASSET_BASE_URL}/${p}`;
}
// A record may store a media reference directly (string) or nested in an object
// (e.g. { sound: { url } }). Read the first key that yields a usable value.
function readField(row: Record<string, unknown>, key: string): string | null {
const v = row[key];
if (typeof v === "string" && v) return v;
if (v && typeof v === "object") {
const obj = v as Record<string, unknown>;
for (const k of ["url", "path", "src", "file"]) {
if (typeof obj[k] === "string" && obj[k]) return obj[k] as string;
}
}
return null;
}
// Find the first present URL among candidate keys, resolved to absolute.
export function pickUrl(
row: unknown,
keys: string[],
): string | null {
if (!row || typeof row !== "object") return null;
const record = row as Record<string, unknown>;
for (const key of keys) {
const found = readField(record, key);
if (found) return assetUrl(found);
}
return null;
}
// Common key sets per media kind. Ordered most-specific / full-URL first.
// (`/media` returns the playable file in `external_url`; `/music` uses `url`.)
export const SOUND_KEYS = [
"url",
"external_url",
"sound_url",
"sound",
"audio_url",
"audio",
"file_url",
"file",
"file_path",
];
export const VIDEO_KEYS = [
"external_url",
"video_url",
"video",
"url",
"file_url",
"file",
"file_path",
];
export const IMAGE_KEYS = [
"image_url",
"image",
"thumbnail",
"thumbnail_url",
"cover",
"url",
"path",
];
+117
View File
@@ -0,0 +1,117 @@
import type { ComponentType, SVGProps } from "react";
import {
BreathIcon,
HomeIcon,
ImageIcon,
MediaIcon,
MoodIcon,
MusicIcon,
QuestionIcon,
SceneIcon,
SliderIcon,
SurveyIcon,
TagIcon,
TimerIcon,
TrophyIcon,
WorryIcon,
} from "@/components/icons";
export interface NavItem {
href: string;
label: string;
}
export interface NavSection {
label: string;
icon: ComponentType<SVGProps<SVGSVGElement>>;
items: NavItem[];
}
export const NAV: NavSection[] = [
{
label: "میزکار",
icon: HomeIcon,
items: [{ href: "/dashboard", label: "خانه" }],
},
{
label: "رسانه‌ها",
icon: MediaIcon,
items: [
{ href: "/dashboard/media", label: "فهرست رسانه‌ها" },
{ href: "/dashboard/media/categories", label: "دسته‌بندی‌ها" },
{ href: "/dashboard/media/sub-categories", label: "زیر‌دسته‌ها" },
],
},
{
label: "موسیقی",
icon: MusicIcon,
items: [
{ href: "/dashboard/music/tracks", label: "آهنگ‌ها" },
{ href: "/dashboard/music/playlists", label: "پلی‌لیست‌ها" },
{ href: "/dashboard/music/categories", label: "دسته‌بندی‌ها" },
{ href: "/dashboard/music/sub-categories", label: "زیر‌دسته‌ها" },
],
},
{
label: "تایمر مدیتیشن",
icon: TimerIcon,
items: [
{ href: "/dashboard/timer/presets", label: "پیش‌تنظیم‌ها" },
{ href: "/dashboard/timer/bell-sounds", label: "صدای زنگ" },
{ href: "/dashboard/timer/background-sounds", label: "صدای پس‌زمینه" },
{ href: "/dashboard/timer/options", label: "گزینه‌های تایمر" },
],
},
{
label: "صحنه‌ها",
icon: SceneIcon,
items: [
{ href: "/dashboard/scenes", label: "صحنه‌ها" },
{ href: "/dashboard/scenes/settings", label: "تنظیمات صحنه" },
],
},
{
label: "اسلایدر",
icon: SliderIcon,
items: [{ href: "/dashboard/sliders", label: "اسلایدرها" }],
},
{
label: "تصاویر",
icon: ImageIcon,
items: [{ href: "/dashboard/images", label: "کتابخانه تصاویر" }],
},
{
label: "تمرین تنفس",
icon: BreathIcon,
items: [{ href: "/dashboard/breathing", label: "قالب‌های تنفس" }],
},
{
label: "پرسش‌ها",
icon: QuestionIcon,
items: [{ href: "/dashboard/questions", label: "بانک پرسش‌ها" }],
},
{
label: "نظرسنجی",
icon: SurveyIcon,
items: [{ href: "/dashboard/surveys", label: "پرسش‌های نظرسنجی" }],
},
{
label: "حال‌وهوا",
icon: MoodIcon,
items: [{ href: "/dashboard/moods", label: "حالت‌ها" }],
},
{
label: "جعبه نگرانی",
icon: WorryIcon,
items: [{ href: "/dashboard/worries", label: "نگرانی‌ها" }],
},
{
label: "جدول امتیازات",
icon: TrophyIcon,
items: [{ href: "/dashboard/leaderboard", label: "رتبه‌بندی" }],
},
{
label: "محتوای کاربران",
icon: TagIcon,
items: [{ href: "/dashboard/comments", label: "نظرات" }],
},
];
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ApiError, apiFetch, unwrap } from "./api";
type Query = Record<string, string | number | boolean | undefined | null>;
// Generic GET-list hook. Unwraps `{ data: [...] }` and tolerates either a bare
// array or a paginated object with a `data` field.
export function useList<T = unknown>(
path: string | null,
query?: Query,
): {
data: T[];
loading: boolean;
error: string | null;
reload: () => void;
} {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const queryKey = JSON.stringify(query ?? {});
const reload = useCallback(() => {
if (!path) {
// No path yet (e.g. a filter not chosen): show an empty, non-loading state.
setData([]);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
apiFetch(path, { query })
.then((res) => {
const value = unwrap<unknown>(res);
setData(Array.isArray(value) ? (value as T[]) : []);
})
.catch((e: unknown) => {
setError(e instanceof ApiError ? e.message : "خطا در دریافت اطلاعات");
setData([]);
})
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path, queryKey]);
useEffect(() => {
reload();
}, [reload]);
return { data, loading, error, reload };
}
// Single-record GET hook.
export function useItem<T = unknown>(path: string | null): {
data: T | null;
loading: boolean;
error: string | null;
reload: () => void;
} {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const reload = useCallback(() => {
if (!path) {
setData(null);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
apiFetch(path)
.then((res) => setData(unwrap<T>(res)))
.catch((e: unknown) =>
setError(e instanceof ApiError ? e.message : "خطا در دریافت اطلاعات"),
)
.finally(() => setLoading(false));
}, [path]);
useEffect(() => {
reload();
}, [reload]);
return { data, loading, error, reload };
}
+19
View File
@@ -0,0 +1,19 @@
// Tiny className combiner (avoids an extra dependency).
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
// Convert ASCII digits to Persian digits for display.
const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
export function toFa(value: string | number | null | undefined): string {
if (value === null || value === undefined) return "";
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
}
// Seconds -> "م:ث" style label (e.g. 90 -> ۱:۳۰).
export function formatDuration(seconds?: number | null): string {
if (!seconds && seconds !== 0) return "—";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return toFa(`${m}:${String(s).padStart(2, "0")}`);
}