feat: Added ConfirmModal component, Edit As button with write-mode conf…
- "frontend/src/components/ConfirmModal.tsx" - "frontend/src/components/ConfirmModal.module.css" - "frontend/src/api/auth.ts" - "frontend/src/context/AuthContext.tsx" - "frontend/src/pages/AdminUsers.tsx" - "frontend/src/pages/AdminUsers.module.css" - "frontend/src/components/ImpersonationBanner.tsx" - "frontend/src/components/ImpersonationBanner.module.css" GSD-Task: S07/T02
This commit is contained in:
parent
5a39850a35
commit
944e152c6f
9 changed files with 291 additions and 25 deletions
|
|
@ -93,16 +93,42 @@ export async function fetchUsers(token: string): Promise<UserListItem[]> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImpersonationLogEntry {
|
||||||
|
id: number;
|
||||||
|
admin_name: string;
|
||||||
|
target_name: string;
|
||||||
|
action: string;
|
||||||
|
write_mode: boolean;
|
||||||
|
ip_address: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function impersonateUser(
|
export async function impersonateUser(
|
||||||
token: string,
|
token: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
writeMode?: boolean,
|
||||||
): Promise<ImpersonateResponse> {
|
): Promise<ImpersonateResponse> {
|
||||||
return request<ImpersonateResponse>(
|
const opts: RequestInit = {
|
||||||
`${BASE}/admin/impersonate/${userId}`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
},
|
};
|
||||||
|
if (writeMode) {
|
||||||
|
(opts.headers as Record<string, string>)["Content-Type"] = "application/json";
|
||||||
|
opts.body = JSON.stringify({ write_mode: true });
|
||||||
|
}
|
||||||
|
return request<ImpersonateResponse>(
|
||||||
|
`${BASE}/admin/impersonate/${userId}`,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImpersonationLog(
|
||||||
|
token: string,
|
||||||
|
page: number = 1,
|
||||||
|
): Promise<ImpersonationLogEntry[]> {
|
||||||
|
return request<ImpersonationLogEntry[]>(
|
||||||
|
`${BASE}/admin/impersonation-log?page=${page}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
93
frontend/src/components/ConfirmModal.module.css
Normal file
93
frontend/src/components/ConfirmModal.module.css
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
animation: fadeIn 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #1e1e2e;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
animation: slideUp 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(12px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #aaa;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms, border-color 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmBtn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms, filter 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmBtn:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmBtn[data-variant="danger"] {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmBtn[data-variant="warning"] {
|
||||||
|
background: #b45309;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
66
frontend/src/components/ConfirmModal.tsx
Normal file
66
frontend/src/components/ConfirmModal.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useEffect, useCallback } from "react";
|
||||||
|
import styles from "./ConfirmModal.module.css";
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
variant?: "warning" | "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmModal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = "Confirm",
|
||||||
|
cancelLabel = "Cancel",
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
variant = "warning",
|
||||||
|
}: ConfirmModalProps) {
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
},
|
||||||
|
[onCancel],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [open, handleKeyDown]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.backdrop}
|
||||||
|
onClick={onCancel}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
<div className={styles.card} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 className={styles.title}>{title}</h2>
|
||||||
|
<p className={styles.message}>{message}</p>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button className={styles.cancelBtn} onClick={onCancel}>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.confirmBtn}
|
||||||
|
data-variant={variant}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -47,3 +47,8 @@
|
||||||
:global(body.impersonating) {
|
:global(body.impersonating) {
|
||||||
padding-top: 40px;
|
padding-top: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Write-mode red banner */
|
||||||
|
.writeMode {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,42 @@ import { useAuth } from "../context/AuthContext";
|
||||||
import styles from "./ImpersonationBanner.module.css";
|
import styles from "./ImpersonationBanner.module.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed amber banner shown when an admin is impersonating a creator.
|
* Fixed banner shown when an admin is impersonating a creator.
|
||||||
* Adds body.impersonating class to push page content down.
|
* Red in write mode, amber in read-only mode.
|
||||||
|
* Adds body.impersonating (and body.impersonating-write) to push page content down.
|
||||||
*/
|
*/
|
||||||
export default function ImpersonationBanner() {
|
export default function ImpersonationBanner() {
|
||||||
const { isImpersonating, user, exitImpersonation } = useAuth();
|
const { isImpersonating, isWriteMode, user, exitImpersonation } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isImpersonating) {
|
if (isImpersonating) {
|
||||||
document.body.classList.add("impersonating");
|
document.body.classList.add("impersonating");
|
||||||
|
if (isWriteMode) {
|
||||||
|
document.body.classList.add("impersonating-write");
|
||||||
} else {
|
} else {
|
||||||
document.body.classList.remove("impersonating");
|
document.body.classList.remove("impersonating-write");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove("impersonating", "impersonating-write");
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
document.body.classList.remove("impersonating");
|
document.body.classList.remove("impersonating", "impersonating-write");
|
||||||
};
|
};
|
||||||
}, [isImpersonating]);
|
}, [isImpersonating, isWriteMode]);
|
||||||
|
|
||||||
if (!isImpersonating) return null;
|
if (!isImpersonating) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.banner} role="alert">
|
<div
|
||||||
|
className={`${styles.banner} ${isWriteMode ? styles.writeMode : ""}`}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
<span className={styles.text}>
|
<span className={styles.text}>
|
||||||
<span className={styles.icon} aria-hidden="true">👁</span>
|
<span className={styles.icon} aria-hidden="true">
|
||||||
Viewing as <strong>{user?.display_name ?? "Unknown"}</strong>
|
{isWriteMode ? "✏️" : "👁"}
|
||||||
|
</span>
|
||||||
|
{isWriteMode ? "Editing" : "Viewing"} as{" "}
|
||||||
|
<strong>{user?.display_name ?? "Unknown"}</strong>
|
||||||
</span>
|
</span>
|
||||||
<button className={styles.exitBtn} onClick={exitImpersonation}>
|
<button className={styles.exitBtn} onClick={exitImpersonation}>
|
||||||
Exit
|
Exit
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,12 @@ interface AuthContextValue {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isImpersonating: boolean;
|
isImpersonating: boolean;
|
||||||
|
isWriteMode: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
register: (data: RegisterRequest) => Promise<UserResponse>;
|
register: (data: RegisterRequest) => Promise<UserResponse>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
startImpersonation: (userId: string) => Promise<void>;
|
startImpersonation: (userId: string, writeMode?: boolean) => Promise<void>;
|
||||||
exitImpersonation: () => Promise<void>;
|
exitImpersonation: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,6 +46,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(!!token);
|
const [loading, setLoading] = useState(!!token);
|
||||||
|
const [isWriteMode, setIsWriteMode] = useState(false);
|
||||||
|
|
||||||
// Rehydrate session from stored token on mount
|
// Rehydrate session from stored token on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -89,13 +91,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startImpersonation = useCallback(async (userId: string) => {
|
const startImpersonation = useCallback(async (userId: string, writeMode?: boolean) => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
// Save admin token so we can restore it later
|
// Save admin token so we can restore it later
|
||||||
sessionStorage.setItem(ADMIN_TOKEN_KEY, token);
|
sessionStorage.setItem(ADMIN_TOKEN_KEY, token);
|
||||||
const resp = await impersonateUser(token, userId);
|
const resp = await impersonateUser(token, userId, writeMode);
|
||||||
localStorage.setItem(AUTH_TOKEN_KEY, resp.access_token);
|
localStorage.setItem(AUTH_TOKEN_KEY, resp.access_token);
|
||||||
setToken(resp.access_token);
|
setToken(resp.access_token);
|
||||||
|
setIsWriteMode(!!writeMode);
|
||||||
const me = await authGetMe(resp.access_token);
|
const me = await authGetMe(resp.access_token);
|
||||||
setUser(me);
|
setUser(me);
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
@ -112,6 +115,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
// Restore admin token
|
// Restore admin token
|
||||||
const adminToken = sessionStorage.getItem(ADMIN_TOKEN_KEY);
|
const adminToken = sessionStorage.getItem(ADMIN_TOKEN_KEY);
|
||||||
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
|
sessionStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||||
|
setIsWriteMode(false);
|
||||||
if (adminToken) {
|
if (adminToken) {
|
||||||
localStorage.setItem(AUTH_TOKEN_KEY, adminToken);
|
localStorage.setItem(AUTH_TOKEN_KEY, adminToken);
|
||||||
setToken(adminToken);
|
setToken(adminToken);
|
||||||
|
|
@ -130,6 +134,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
token,
|
token,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
isImpersonating: !!user?.impersonating,
|
isImpersonating: !!user?.impersonating,
|
||||||
|
isWriteMode,
|
||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,33 @@
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actionBtns {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editAsBtn {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms, color 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editAsBtn:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editAsBtn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.loading,
|
.loading,
|
||||||
.error,
|
.error,
|
||||||
.empty {
|
.empty {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { fetchUsers, type UserListItem } from "../api";
|
import { fetchUsers, type UserListItem } from "../api";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||||
|
import ConfirmModal from "../components/ConfirmModal";
|
||||||
import styles from "./AdminUsers.module.css";
|
import styles from "./AdminUsers.module.css";
|
||||||
|
|
||||||
export default function AdminUsers() {
|
export default function AdminUsers() {
|
||||||
|
|
@ -11,6 +12,7 @@ export default function AdminUsers() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [impersonating, setImpersonating] = useState<string | null>(null);
|
const [impersonating, setImpersonating] = useState<string | null>(null);
|
||||||
|
const [editTarget, setEditTarget] = useState<UserListItem | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
@ -25,13 +27,24 @@ export default function AdminUsers() {
|
||||||
setImpersonating(userId);
|
setImpersonating(userId);
|
||||||
try {
|
try {
|
||||||
await startImpersonation(userId);
|
await startImpersonation(userId);
|
||||||
// Navigation will happen via auth context update
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message || "Failed to start impersonation");
|
setError(e.message || "Failed to start impersonation");
|
||||||
setImpersonating(null);
|
setImpersonating(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleEditAsConfirm() {
|
||||||
|
if (!editTarget) return;
|
||||||
|
setEditTarget(null);
|
||||||
|
setImpersonating(editTarget.id);
|
||||||
|
try {
|
||||||
|
await startImpersonation(editTarget.id, true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Failed to start write-mode impersonation");
|
||||||
|
setImpersonating(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
|
|
@ -77,6 +90,7 @@ export default function AdminUsers() {
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{u.role === "creator" && u.id !== currentUser?.id && (
|
{u.role === "creator" && u.id !== currentUser?.id && (
|
||||||
|
<div className={styles.actionBtns}>
|
||||||
<button
|
<button
|
||||||
className={styles.viewAsBtn}
|
className={styles.viewAsBtn}
|
||||||
disabled={impersonating === u.id}
|
disabled={impersonating === u.id}
|
||||||
|
|
@ -84,6 +98,14 @@ export default function AdminUsers() {
|
||||||
>
|
>
|
||||||
{impersonating === u.id ? "Switching…" : "View As"}
|
{impersonating === u.id ? "Switching…" : "View As"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.editAsBtn}
|
||||||
|
disabled={impersonating === u.id}
|
||||||
|
onClick={() => setEditTarget(u)}
|
||||||
|
>
|
||||||
|
Edit As
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -91,6 +113,16 @@ export default function AdminUsers() {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmModal
|
||||||
|
open={!!editTarget}
|
||||||
|
title="Enable Write Mode"
|
||||||
|
message={`You are about to edit as ${editTarget?.display_name ?? "this user"}. Changes will be attributed to this creator and logged. Continue?`}
|
||||||
|
confirmLabel="Enable Write Mode"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
variant="danger"
|
||||||
|
onConfirm={handleEditAsConfirm}
|
||||||
|
onCancel={() => setEditTarget(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"errors":true,"version":"5.6.3"}
|
||||||
Loading…
Add table
Reference in a new issue