feat: initial
This commit is contained in:
+133
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
@@ -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: "نظرات" }],
|
||||
},
|
||||
];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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")}`);
|
||||
}
|
||||
Reference in New Issue
Block a user