= ({
- projectId,
- repoOwner,
- repoName,
+ projectId,
+ repoOwner,
+ repoName,
}) => {
-
- const computeHash = async (content: string): Promise => {
- const msgBuffer = new TextEncoder().encode(content);
- const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
- const hashArray = Array.from(new Uint8Array(hashBuffer));
- return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
- };
-
- const getSize = (content: string): number => {
- return new TextEncoder().encode(content).length;
- };
-
- const [selectedFile, setSelectedFile] = useState(null);
- const [fileContent, setFileContent] = useState("");
- const [originalHash, setOriginalHash] = useState("");
- const [originalSize, setOriginalSize] = useState(0);
- const [isLoading, setIsLoading] = useState(false);
- const [error, setError] = useState(null);
- const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
- const [isCommitPanelOpen, setIsCommitPanelOpen] = useState(false);
- const [drafts, setDrafts] = useState>({});
- const [mdxError, setMdxError] = useState(null);
- const [isMounted, setIsMounted] = useState(false);
- const [editorRef, setEditorRef] = useState(null);
- const [showDraftModal, setShowDraftModal] = useState(false);
- const [pendingDraft, setPendingDraft] = useState<{
- content: string;
- timestamp: number;
- filePath: string;
- } | null>(null);
- const [pendingCreates, setPendingCreates] = useState<
- Array<{ path: string; type: "file" | "folder" }>
- >([]);
-
- // Mount tracking for hydration fix
- useEffect(() => {
- // console.log("[MDXEditorToast] Component mounted", {
- // projectId,
- // repoOwner,
- // repoName,
- // });
- setIsMounted(true);
- }, [projectId, repoOwner, repoName]);
-
-
- useEffect(() => {
- const savedDrafts = localStorage.getItem(`mdx-drafts-${projectId}`);
- if (savedDrafts) {
- try {
- setDrafts(JSON.parse(savedDrafts));
- } catch (e) {
- console.error("Failed to load drafts:", e);
- }
- }
- }, [projectId]);
-
-
- useEffect(() => {
- if (Object.keys(drafts).length > 0) {
- localStorage.setItem(`mdx-drafts-${projectId}`, JSON.stringify(drafts));
- }
- }, [drafts, projectId]);
-
- const loadFileContent = useCallback(
- async (file: FileItem) => {
- if (file.type === "dir") return;
-
- const isPending = pendingCreates.some(
- (p) => p.path === file.path && p.type === "file",
- );
-
- if (isPending) {
- // for pending files, content is in drafts or empty
- const draftKey = `${repoOwner}/${repoName}/${file.path}`;
- const content = drafts[draftKey]?.content || "# New File Content";
- setFileContent(content);
- // for new files, original is empty
- setOriginalHash(await computeHash(""));
- setOriginalSize(0);
- setSelectedFile(file);
- setHasUnsavedChanges(!!content);
- return;
- }
-
- setIsLoading(true);
- setError(null);
-
- try {
- const response = await (api.projects as any)[projectId].repo[repoOwner][
- repoName
- ].file.$get({
- query: {
- path: file.path,
- },
- });
-
- if (!response.ok) {
- throw new Error(`Failed to load file: ${response.statusText}`);
- }
-
- const data = await response.json();
- const content = data.data.content || "";
-
- setFileContent(content);
-
- // compute hash and size for original content
- const hash = await computeHash(content);
- const size = getSize(content);
- setOriginalHash(hash);
- setOriginalSize(size);
-
- setSelectedFile(file);
- setHasUnsavedChanges(false);
-
- // check if there's a draft for this file
- const draftKey = `${repoOwner}/${repoName}/${file.path}`;
- if (drafts[draftKey]) {
- const draft = drafts[draftKey];
- if (draft.content !== content) {
- setPendingDraft(draft);
- setShowDraftModal(true);
- }
- }
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to load file");
- } finally {
- setIsLoading(false);
- }
- },
- [projectId, repoOwner, repoName, drafts, pendingCreates],
- );
-
- const handleContentChange = useCallback((content: string) => {
- setFileContent(content);
- }, []);
-
- // async change detection using hash and size
- useEffect(() => {
- const checkChanges = async () => {
- if (!selectedFile) return;
-
- // metadata check (size)
- const currentSize = getSize(fileContent);
- if (currentSize !== originalSize) {
- setHasUnsavedChanges(true);
- return;
- }
-
- // cryptographic hash check
- const currentHash = await computeHash(fileContent);
- setHasUnsavedChanges(currentHash !== originalHash);
- };
-
- checkChanges();
- }, [fileContent, originalSize, originalHash, selectedFile]);
-
- // auto-save draft
- useEffect(() => {
- if (hasUnsavedChanges && selectedFile) {
- const timeoutId = setTimeout(() => {
- saveDraft();
- }, 2000); // auto-save after 2 seconds of inactivity
-
- return () => clearTimeout(timeoutId);
- }
- }, [fileContent, hasUnsavedChanges, selectedFile]);
-
- // Save draft manually
- const saveDraft = useCallback(() => {
- if (!selectedFile) return;
-
- const draftKey = `${repoOwner}/${repoName}/${selectedFile.path}`;
- const newDrafts = {
- ...drafts,
- [draftKey]: {
- content: fileContent,
- timestamp: Date.now(),
- filePath: selectedFile.path,
- },
- };
- setDrafts(newDrafts);
- }, [drafts, fileContent, repoOwner, repoName, selectedFile]);
-
-
- const handleFileCreate = useCallback(
- (path: string, type: "file" | "folder") => {
- setPendingCreates((prev) => [...prev, { path, type }]);
-
- if (type === "file") {
- const newFile: FileItem = {
- name: path.split("/").pop() || "",
- path,
- type: "file",
- size: 0,
- sha: "",
- };
-
- const draftKey = `${repoOwner}/${repoName}/${path}`;
- setDrafts((prev) => ({
- ...prev,
- [draftKey]: {
- content: "# New File Content",
- timestamp: Date.now(),
- filePath: path,
- },
- }));
- setSelectedFile(newFile);
- setFileContent("# New File Content");
-
- computeHash("").then((hash) => {
- setOriginalHash(hash);
- setOriginalSize(0);
- });
- setHasUnsavedChanges(true); // treat new file as having changes so it can be committed
- }
- },
- [repoOwner, repoName],
- );
-
- const handleCommit = useCallback(
- async (
- commitMessage: string,
- filesToCommit: {
- path: string;
- content: string;
- }[],
- ) => {
- try {
-
- const allFilesToCommit = [...filesToCommit];
-
- pendingCreates.forEach((item) => {
- if (item.type === "file") {
- // ensure pending files are included even if empty/unchanged
- // (though usually they'd be in filesToCommit via getUnsavedFiles)
- const exists = allFilesToCommit.some((f) => f.path === item.path);
- if (!exists) {
- const draftKey = `${repoOwner}/${repoName}/${item.path}`;
- const content = drafts[draftKey]?.content || "";
- allFilesToCommit.push({
- path: item.path,
- content,
- });
- }
- }
- });
-
- if (allFilesToCommit.length === 0) {
- alert("No changes to commit.");
- return;
- }
-
- const response = await (api.projects as any)[projectId].repo[repoOwner][
- repoName
- ]["bulk-update"].$post({
- json: {
- files: allFilesToCommit,
- message: commitMessage,
- },
- });
-
- if (!response.ok) {
- throw new Error(`Failed to commit changes: ${response.statusText}`);
- }
-
- allFilesToCommit.forEach(async (file) => {
- try {
- await actions.projectsActions.logActivity({
- projectId,
- actionType: "commit",
- filePath: file.path,
- fileName: file.path.split("/").pop() || "",
- fileSize: getSize(file.content),
- changesSummary: commitMessage,
- });
- } catch (error) {
- console.error("Failed to log activity:", error);
- }
- });
-
- const newDrafts = { ...drafts };
- allFilesToCommit.forEach((file) => {
- const draftKey = `${repoOwner}/${repoName}/${file.path}`;
- delete newDrafts[draftKey];
- });
- setDrafts(newDrafts);
- setPendingCreates([]);
-
- if (
- selectedFile &&
- allFilesToCommit.some((f) => f.path === selectedFile.path)
- ) {
- // update original hash/size to match committed content
- const newHash = await computeHash(fileContent);
- const newSize = getSize(fileContent);
- setOriginalHash(newHash);
- setOriginalSize(newSize);
- setHasUnsavedChanges(false);
- }
-
- setIsCommitPanelOpen(false);
- alert("Changes committed successfully!");
-
- // trigger reload in FileExplorer (via window event as implemented before) - could be improved
- window.dispatchEvent(new Event("project-settings-updated"));
- } catch (err) {
- alert(err instanceof Error ? err.message : "Failed to commit changes");
- }
- },
- [
- projectId,
- repoOwner,
- repoName,
- drafts,
- selectedFile,
- fileContent,
- pendingCreates,
- ],
- );
-
-
- const getUnsavedFiles = useCallback(() => {
- const unsavedFiles: {
- path: string;
- content: string;
- }[] = [];
-
- if (hasUnsavedChanges && selectedFile) {
- unsavedFiles.push({
- path: selectedFile.path,
- content: fileContent,
- });
- }
-
- Object.entries(drafts).forEach(([draftKey, draft]) => {
- const filePath = draft.filePath;
- if (!selectedFile || filePath !== selectedFile.path) {
- unsavedFiles.push({
- path: filePath,
- content: draft.content,
- });
- }
- });
-
- return unsavedFiles;
- }, [hasUnsavedChanges, selectedFile, fileContent, drafts]);
-
- return (
-
-
-
-
-
Files
-
- {repoOwner}/{repoName}
-
-
-
-
-
- {/* Editor Area */}
-
- {/* Editor Header */}
-
-
- {selectedFile ? (
-
-
- {selectedFile.name}
-
-
- {selectedFile.path}
-
-
- ) : (
-
Select a file to edit
- )}
- {hasUnsavedChanges && (
-
- )}
-
-
- {hasUnsavedChanges && (
-
- Save Draft
-
- )}
- {Object.keys(drafts).length > 0 && (
- {
- // Restore the most recent draft
- const draftKeys = Object.keys(drafts);
- const mostRecentKey = draftKeys.reduce((a, b) =>
- drafts[a].timestamp > drafts[b].timestamp ? a : b,
- );
- const draft = drafts[mostRecentKey];
- setFileContent(draft.content);
- setHasUnsavedChanges(true);
- // Remove the restored draft
- const newDrafts = { ...drafts };
- delete newDrafts[mostRecentKey];
- setDrafts(newDrafts);
- }}
- className="px-3 py-1.5 text-sm bg-blue-100 text-blue-600 rounded-md hover:bg-blue-200 transition-colors"
- >
- Restore Draft
-
- )}
- setIsCommitPanelOpen(true)}
- disabled={
- !isMounted ||
- (getUnsavedFiles().length === 0 && pendingCreates.length === 0)
- }
- className="px-4 py-1.5 text-sm bg-earth-300 text-white rounded-md hover:bg-earth-500 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
- >
- Commit Changes (
- {isMounted ? getUnsavedFiles().length + pendingCreates.length : 0}
- )
-
-
-
-
- {/* Editor Content */}
-
- {error && (
-
- )}
-
- {mdxError && (
-
-
-
MDX Parsing Error: {mdxError}
-
-
- You can fix the errors in source mode and switch to rich
- text mode when you are ready.
-
-
- {
- // Enhanced auto-fix for common issues
- const fixedContent = fileContent
- // Fix HTML tags with unquoted attributes
- .replace(/<([^>]+)>/g, (match) => {
- return (
- match
- // Quote numeric values
- .replace(/(\w+)=(\d+)/g, '$1="$2"')
- // Quote boolean values
- .replace(/(\w+)=(true|false)/g, '$1="$2"')
- // Quote other unquoted attributes
- .replace(/(\w+)=([^"'\s>=]+)/g, '$1="$2"')
- // Fix spacing issues
- .replace(/\s+/g, " ")
- .trim()
- );
- })
- // Convert HTML comments to JSX comments
- .replace(//g, "{/*$1*/}")
- // Remove DOCTYPE declarations
- .replace(/]*>/gi, "")
- // Escape standalone curly braces that aren't JSX
- .replace(/{/g, "\\{")
- .replace(/}/g, "\\}");
- setFileContent(fixedContent);
- setMdxError(null);
- }}
- className="px-2 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700"
- >
- Auto Fix
-
- setMdxError(null)}
- className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700"
- >
- Dismiss
-
-
-
-
-
- )}
-
- {isLoading ? (
-
- ) : selectedFile ? (
-
- {isMounted ? (
- // Toast UI Editor
-
- {
- if (editorRef) {
- const content = editorRef.getInstance().getMarkdown();
- handleContentChange(content);
- }
- }}
- toolbarItems={[
- ["heading", "bold", "italic", "strike"],
- ["hr", "quote"],
- ["ul", "ol", "task", "indent", "outdent"],
- ["table", "image", "link"],
- ["code", "codeblock"],
- ["scrollSync"],
- ]}
- />
-
- ) : (
- // Loading state during hydration
-
- )}
-
- ) : (
-
-
-
-
-
-
No file selected
-
Choose a file from the explorer to start editing
-
-
- )}
-
-
-
- {/* Commit Panel */}
- {isCommitPanelOpen && (
-
setIsCommitPanelOpen(false)}
- />
- )}
-
- {/* Draft Restore Modal */}
- {showDraftModal && pendingDraft && (
-
-
-
- Unsaved Changes Found
-
-
- You have unsaved changes for this file from{" "}
-
- {new Date(pendingDraft.timestamp).toLocaleString()}
-
- . Would you like to restore them?
-
-
- {
- // remove the draft
- const draftKey = `${repoOwner}/${repoName}/${pendingDraft.filePath}`;
- const newDrafts = { ...drafts };
- delete newDrafts[draftKey];
- setDrafts(newDrafts);
- setShowDraftModal(false);
- setPendingDraft(null);
- }}
- className="px-4 py-2 text-sm text-earth-300 border border-earth-200 rounded-md hover:bg-earth-50 transition-colors"
- >
- Discard Changes
-
- {
- // Restore the draft
- setFileContent(pendingDraft.content);
- setHasUnsavedChanges(true);
- setShowDraftModal(false);
- setPendingDraft(null);
- }}
- className="px-4 py-2 text-sm bg-earth-200 text-white rounded-md hover:bg-earth-400 transition-colors"
- >
- Restore Changes
-
-
-
-
- )}
-
- );
+ const computeHash = async (content: string): Promise => {
+ const msgBuffer = new TextEncoder().encode(content);
+ const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
+ };
+
+ const getSize = (content: string): number => {
+ return new TextEncoder().encode(content).length;
+ };
+
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [fileContent, setFileContent] = useState("");
+ const [originalHash, setOriginalHash] = useState("");
+ const [originalSize, setOriginalSize] = useState(0);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+ const [isCommitPanelOpen, setIsCommitPanelOpen] = useState(false);
+ const [drafts, setDrafts] = useState>({});
+ const [mdxError, setMdxError] = useState(null);
+ const [repoConfig, setRepoConfig] = useState([]);
+ const [schemaErrors, setSchemaErrors] = useState([]);
+ const [isMounted, setIsMounted] = useState(false);
+ const [EditorComponent, setEditorComponent] = useState(null);
+ const [editorRef, setEditorRef] = useState(null);
+ const validationTimeoutRef = useRef(null);
+ const [showDraftModal, setShowDraftModal] = useState(false);
+ const [pendingDraft, setPendingDraft] = useState<{
+ content: string;
+ timestamp: number;
+ filePath: string;
+ } | null>(null);
+ const [pendingCreates, setPendingCreates] = useState<
+ Array<{ path: string; type: "file" | "folder" }>
+ >([]);
+ const [mediaPath, setMediaPath] = useState("");
+ const [showMediaPanel, setShowMediaPanel] = useState(false);
+
+ // Fetch media base path from config
+ useEffect(() => {
+ const fetchMediaPath = async () => {
+ try {
+ const response = await (api.projects as any)[projectId].repo[repoOwner][
+ repoName
+ ].config.$get();
+ if (response.ok) {
+ const data = await response.json();
+ const config = data.data;
+ const firstWithImagePath = config.find((c: any) => c.base_image_path);
+ if (firstWithImagePath?.base_image_path) {
+ setMediaPath(firstWithImagePath.base_image_path);
+ }
+ }
+ } catch (err) {
+ console.error("Failed to fetch media path:", err);
+ }
+ };
+ fetchMediaPath();
+ }, [projectId, repoOwner, repoName]);
+
+ const insertImageMarkdown = useCallback(
+ (imageUrl: string) => {
+ if (!editorRef) return;
+
+ const imageMarkdown = `\n\n`;
+ const currentContent = fileContent;
+ const newContent = currentContent + imageMarkdown;
+ setFileContent(newContent);
+ setHasUnsavedChanges(true);
+ },
+ [editorRef, fileContent],
+ );
+
+ useEffect(() => {
+ const savedDrafts = localStorage.getItem(`mdx-drafts-${projectId}`);
+ if (savedDrafts) {
+ try {
+ setDrafts(JSON.parse(savedDrafts));
+ } catch (e) {
+ console.error("Failed to load drafts:", e);
+ }
+ }
+ }, [projectId]);
+
+ useEffect(() => {
+ if (Object.keys(drafts).length > 0) {
+ localStorage.setItem(`mdx-drafts-${projectId}`, JSON.stringify(drafts));
+ }
+ }, [drafts, projectId]);
+
+ // Fetch repo config for validation
+ useEffect(() => {
+ const fetchConfig = async () => {
+ try {
+ const response = await (api.projects as any)[projectId].repo[repoOwner][
+ repoName
+ ].config.$get();
+ if (response.ok) {
+ const data = await response.json();
+ setRepoConfig(data.data);
+ }
+ } catch (err) {
+ console.error("Failed to fetch repo config:", err);
+ }
+ };
+ fetchConfig();
+ }, [projectId, repoOwner, repoName]);
+
+ const validateContent = useCallback(
+ async (content: string, path: string) => {
+ const config = repoConfig.find(
+ (c) => path.startsWith(c.path + "/") || path === c.path,
+ );
+ if (!config?.schema) {
+ setSchemaErrors([]);
+ return;
+ }
+
+ const errors: string[] = [];
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
+
+ if (!match) {
+ errors.push(
+ "Missing frontmatter (---) correctly positioned at the start.",
+ );
+ setSchemaErrors(errors);
+ return;
+ }
+
+ try {
+ const YAML = (await import("yaml")).default;
+ const frontmatter = YAML.parse(match[1]);
+
+ Object.entries(config.schema).forEach(
+ ([key, schema]: [string, any]) => {
+ const value = frontmatter[key];
+
+ // Only validate required fields (required defaults to true if not specified)
+ if (
+ schema.required !== false &&
+ (value === undefined || value === null || value === "")
+ ) {
+ errors.push(`Field "${key}" is required.`);
+ return;
+ }
+
+ // Skip further validation if field is empty and not required
+ if (
+ schema.required === false &&
+ (value === undefined || value === null || value === "")
+ ) {
+ return;
+ }
+
+ if (schema.type === "string") {
+ const strValue = String(value);
+ if (schema.min && strValue.length < schema.min) {
+ errors.push(
+ `"${key}" must be at least ${schema.min} characters.`,
+ );
+ }
+ if (schema.max && strValue.length > schema.max) {
+ errors.push(
+ `"${key}" must be at most ${schema.max} characters.`,
+ );
+ }
+ } else if (schema.type === "date") {
+ const date = new Date(value);
+ if (isNaN(date.getTime())) {
+ errors.push(`"${key}" must be a valid date.`);
+ }
+ if (schema.format) {
+ // Simple format check (e.g. YYYY-MM-DD vs YYYY/MM/DD)
+ if (
+ schema.format === "YYYY-MM-DD" &&
+ !/^\d{4}-\d{2}-\d{2}$/.test(value)
+ ) {
+ errors.push(`"${key}" must be in YYYY-MM-DD format.`);
+ }
+ }
+ }
+ },
+ );
+ } catch (e) {
+ errors.push("Invalid YAML in frontmatter.");
+ }
+
+ setSchemaErrors(errors);
+ },
+ [repoConfig],
+ );
+
+ // Mount tracking and dynamic editor loading
+ useEffect(() => {
+ setIsMounted(true);
+
+ const loadEditor = async () => {
+ try {
+ const [editorModule] = await Promise.all([
+ import("@toast-ui/react-editor"),
+ import("@toast-ui/editor/dist/toastui-editor.css"),
+ ]);
+ setEditorComponent(() => editorModule.Editor);
+ } catch (err) {
+ console.error("Failed to load editor modules:", err);
+ setError("Failed to load editor component");
+ }
+ };
+
+ loadEditor();
+ }, [projectId, repoOwner, repoName]);
+
+ useEffect(() => {
+ if (selectedFile) {
+ if (validationTimeoutRef.current) {
+ clearTimeout(validationTimeoutRef.current);
+ }
+
+ validationTimeoutRef.current = setTimeout(() => {
+ validateContent(fileContent, selectedFile.path);
+ }, 500); // 500ms debounce
+ }
+
+ return () => {
+ if (validationTimeoutRef.current) {
+ clearTimeout(validationTimeoutRef.current);
+ }
+ };
+ }, [fileContent, selectedFile, validateContent]);
+
+ const loadFileContent = useCallback(
+ async (file: FileItem) => {
+ if (file.type === "dir") return;
+
+ const isPending = pendingCreates.some(
+ (p) => p.path === file.path && p.type === "file",
+ );
+
+ if (isPending) {
+ // for pending files, content is in drafts or empty
+ const draftKey = `${repoOwner}/${repoName}/${file.path}`;
+ const content = drafts[draftKey]?.content || "# New File Content";
+ setFileContent(content);
+ // for new files, original is empty
+ setOriginalHash(await computeHash(""));
+ setOriginalSize(0);
+ setSelectedFile(file);
+ setHasUnsavedChanges(!!content);
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await (api.projects as any)[projectId].repo[repoOwner][
+ repoName
+ ].file.$get({
+ query: {
+ path: file.path,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to load file: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ const content = data.data.content || "";
+
+ setFileContent(content);
+
+ // compute hash and size for original content
+ const hash = await computeHash(content);
+ const size = getSize(content);
+ setOriginalHash(hash);
+ setOriginalSize(size);
+
+ setSelectedFile(file);
+ setHasUnsavedChanges(false);
+
+ // check if there's a draft for this file
+ const draftKey = `${repoOwner}/${repoName}/${file.path}`;
+ if (drafts[draftKey]) {
+ const draft = drafts[draftKey];
+ if (draft.content !== content) {
+ setPendingDraft(draft);
+ setShowDraftModal(true);
+ }
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load file");
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [projectId, repoOwner, repoName, drafts, pendingCreates],
+ );
+
+ const handleContentChange = useCallback((content: string) => {
+ setFileContent(content);
+ }, []);
+
+ // async change detection using hash and size
+ useEffect(() => {
+ const checkChanges = async () => {
+ if (!selectedFile) return;
+
+ // metadata check (size)
+ const currentSize = getSize(fileContent);
+ if (currentSize !== originalSize) {
+ setHasUnsavedChanges(true);
+ return;
+ }
+
+ // cryptographic hash check
+ const currentHash = await computeHash(fileContent);
+ setHasUnsavedChanges(currentHash !== originalHash);
+ };
+
+ checkChanges();
+ }, [fileContent, originalSize, originalHash, selectedFile]);
+
+ // auto-save draft
+ useEffect(() => {
+ if (hasUnsavedChanges && selectedFile) {
+ const timeoutId = setTimeout(() => {
+ saveDraft();
+ }, 2000); // auto-save after 2 seconds of inactivity
+
+ return () => clearTimeout(timeoutId);
+ }
+ }, [fileContent, hasUnsavedChanges, selectedFile]);
+
+ // Save draft manually
+ const saveDraft = useCallback(() => {
+ if (!selectedFile) return;
+
+ const draftKey = `${repoOwner}/${repoName}/${selectedFile.path}`;
+ const newDrafts = {
+ ...drafts,
+ [draftKey]: {
+ content: fileContent,
+ timestamp: Date.now(),
+ filePath: selectedFile.path,
+ },
+ };
+ setDrafts(newDrafts);
+ }, [drafts, fileContent, repoOwner, repoName, selectedFile]);
+
+ const handleFileCreate = useCallback(
+ (path: string, type: "file" | "folder") => {
+ setPendingCreates((prev) => [...prev, { path, type }]);
+
+ if (type === "file") {
+ const newFile: FileItem = {
+ name: path.split("/").pop() || "",
+ path,
+ type: "file",
+ size: 0,
+ sha: "",
+ };
+
+ const draftKey = `${repoOwner}/${repoName}/${path}`;
+ setDrafts((prev) => ({
+ ...prev,
+ [draftKey]: {
+ content: "# New File Content",
+ timestamp: Date.now(),
+ filePath: path,
+ },
+ }));
+ setSelectedFile(newFile);
+ setFileContent("# New File Content");
+
+ computeHash("").then((hash) => {
+ setOriginalHash(hash);
+ setOriginalSize(0);
+ });
+ setHasUnsavedChanges(true); // treat new file as having changes so it can be committed
+ }
+ },
+ [repoOwner, repoName],
+ );
+
+ const handleCommit = useCallback(
+ async (
+ commitMessage: string,
+ filesToCommit: {
+ path: string;
+ content: string;
+ }[],
+ ) => {
+ try {
+ if (schemaErrors.length > 0) {
+ alert("Please fix schema errors before committing.");
+ return;
+ }
+
+ const allFilesToCommit = [...filesToCommit];
+
+ pendingCreates.forEach((item) => {
+ if (item.type === "file") {
+ // ensure pending files are included even if empty/unchanged
+ // (though usually they'd be in filesToCommit via getUnsavedFiles)
+ const exists = allFilesToCommit.some((f) => f.path === item.path);
+ if (!exists) {
+ const draftKey = `${repoOwner}/${repoName}/${item.path}`;
+ const content = drafts[draftKey]?.content || "";
+ allFilesToCommit.push({
+ path: item.path,
+ content,
+ });
+ }
+ }
+ });
+
+ if (allFilesToCommit.length === 0) {
+ alert("No changes to commit.");
+ return;
+ }
+
+ const response = await (api.projects as any)[projectId].repo[repoOwner][
+ repoName
+ ]["bulk-update"].$post({
+ json: {
+ files: allFilesToCommit,
+ message: commitMessage,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to commit changes: ${response.statusText}`);
+ }
+
+ allFilesToCommit.forEach(async (file) => {
+ try {
+ await actions.projectsActions.logActivity({
+ projectId,
+ actionType: "commit",
+ filePath: file.path,
+ fileName: file.path.split("/").pop() || "",
+ fileSize: getSize(file.content),
+ changesSummary: commitMessage,
+ });
+ } catch (error) {
+ console.error("Failed to log activity:", error);
+ }
+ });
+
+ const newDrafts = { ...drafts };
+ allFilesToCommit.forEach((file) => {
+ const draftKey = `${repoOwner}/${repoName}/${file.path}`;
+ delete newDrafts[draftKey];
+ });
+ setDrafts(newDrafts);
+ setPendingCreates([]);
+
+ if (
+ selectedFile &&
+ allFilesToCommit.some((f) => f.path === selectedFile.path)
+ ) {
+ // update original hash/size to match committed content
+ const newHash = await computeHash(fileContent);
+ const newSize = getSize(fileContent);
+ setOriginalHash(newHash);
+ setOriginalSize(newSize);
+ setHasUnsavedChanges(false);
+ }
+
+ setIsCommitPanelOpen(false);
+ alert("Changes committed successfully!");
+
+ // trigger reload in FileExplorer (via window event as implemented before) - could be improved
+ window.dispatchEvent(new Event("project-settings-updated"));
+ } catch (err) {
+ alert(err instanceof Error ? err.message : "Failed to commit changes");
+ }
+ },
+ [
+ projectId,
+ repoOwner,
+ repoName,
+ drafts,
+ selectedFile,
+ fileContent,
+ pendingCreates,
+ schemaErrors,
+ ],
+ );
+
+ const getUnsavedFiles = useCallback(() => {
+ const unsavedFiles: {
+ path: string;
+ content: string;
+ }[] = [];
+
+ if (hasUnsavedChanges && selectedFile) {
+ unsavedFiles.push({
+ path: selectedFile.path,
+ content: fileContent,
+ });
+ }
+
+ Object.entries(drafts).forEach(([draftKey, draft]) => {
+ const filePath = draft.filePath;
+ if (!selectedFile || filePath !== selectedFile.path) {
+ unsavedFiles.push({
+ path: filePath,
+ content: draft.content,
+ });
+ }
+ });
+
+ return unsavedFiles;
+ }, [hasUnsavedChanges, selectedFile, fileContent, drafts]);
+
+ return (
+
+
+
+
Files
+
+ {repoOwner}/{repoName}
+
+
+
+
+
+ {/* Media Panel */}
+ {showMediaPanel && mediaPath && (
+
+
+
+ )}
+
+ {/* Editor Area */}
+
+ {/* Editor Header */}
+
+
+ {selectedFile ? (
+
+
+ {selectedFile.name}
+
+
+ {selectedFile.path}
+
+
+ ) : (
+
Select a file to edit
+ )}
+ {hasUnsavedChanges && (
+
+ )}
+ {schemaErrors.length > 0 && (
+
+
+
+
+
+ Schema Mismatch
+
+
+ )}
+
+
+ {hasUnsavedChanges && (
+
+ Save Draft
+
+ )}
+ {Object.keys(drafts).length > 0 && (
+
{
+ // Restore the most recent draft
+ const draftKeys = Object.keys(drafts);
+ const mostRecentKey = draftKeys.reduce((a, b) =>
+ drafts[a].timestamp > drafts[b].timestamp ? a : b,
+ );
+ const draft = drafts[mostRecentKey];
+ setFileContent(draft.content);
+ setHasUnsavedChanges(true);
+ // Remove the restored draft
+ const newDrafts = { ...drafts };
+ delete newDrafts[mostRecentKey];
+ setDrafts(newDrafts);
+ }}
+ className="px-3 py-1.5 text-sm bg-blue-100 text-blue-600 rounded-md hover:bg-blue-200 transition-colors"
+ >
+ Restore Draft
+
+ )}
+
{
+ if (schemaErrors.length > 0) {
+ alert(
+ "Please fix schema errors: \n" + schemaErrors.join("\n"),
+ );
+ return;
+ }
+ setIsCommitPanelOpen(true);
+ }}
+ disabled={
+ !isMounted ||
+ (getUnsavedFiles().length === 0 &&
+ pendingCreates.length === 0) ||
+ schemaErrors.length > 0
+ }
+ className="px-4 py-1.5 text-sm bg-earth-300 text-white rounded-md hover:bg-earth-500 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ Commit Changes (
+ {isMounted ? getUnsavedFiles().length + pendingCreates.length : 0}
+ )
+
+ {/* Media Panel Toggle */}
+
setShowMediaPanel(!showMediaPanel)}
+ className={`p-2 rounded-md transition-colors ${
+ showMediaPanel
+ ? "bg-earth-200 text-earth-500"
+ : "text-earth-400 hover:bg-earth-100"
+ }`}
+ title={showMediaPanel ? "Hide media panel" : "Show media panel"}
+ >
+
+
+
+
+
+
+
+ {/* Editor Content */}
+
+ {error && (
+
+ )}
+
+ {schemaErrors.length > 0 && (
+
+
+ Schema Errors:
+
+
+ {schemaErrors.map((err, i) => (
+
+ {err}
+
+ ))}
+
+
+ )}
+
+ {mdxError && (
+
+
+
MDX Parsing Error: {mdxError}
+
+
+ You can fix the errors in source mode and switch to rich
+ text mode when you are ready.
+
+
+ {
+ // Enhanced auto-fix for common issues
+ const fixedContent = fileContent
+ // Fix HTML tags with unquoted attributes
+ .replace(/<([^>]+)>/g, (match) => {
+ return (
+ match
+ // Quote numeric values
+ .replace(/(\w+)=(\d+)/g, '$1="$2"')
+ // Quote boolean values
+ .replace(/(\w+)=(true|false)/g, '$1="$2"')
+ // Quote other unquoted attributes
+ .replace(/(\w+)=([^"'\s>=]+)/g, '$1="$2"')
+ // Fix spacing issues
+ .replace(/\s+/g, " ")
+ .trim()
+ );
+ })
+ // Convert HTML comments to JSX comments
+ .replace(//g, "{/*$1*/}")
+ // Remove DOCTYPE declarations
+ .replace(/]*>/gi, "")
+ // Escape standalone curly braces that aren't JSX
+ .replace(/{/g, "\\{")
+ .replace(/}/g, "\\}");
+ setFileContent(fixedContent);
+ setMdxError(null);
+ }}
+ className="px-2 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700"
+ >
+ Auto Fix
+
+ setMdxError(null)}
+ className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700"
+ >
+ Dismiss
+
+
+
+
+
+ )}
+
+ {isLoading ? (
+
+
+
+
+
+
+ Loading file content...
+
+
+ ) : selectedFile ? (
+
+ {isMounted && EditorComponent ? (
+ // Toast UI Editor
+
+ {
+ if (editorRef) {
+ const content = editorRef.getInstance().getMarkdown();
+ handleContentChange(content);
+ }
+ }}
+ toolbarItems={[
+ ["heading", "bold", "italic", "strike"],
+ ["hr", "quote"],
+ ["ul", "ol", "task", "indent", "outdent"],
+ ["table", "image", "link"],
+ ["code", "codeblock"],
+ ["scrollSync"],
+ ]}
+ />
+
+ ) : (
+ // Loading state during hydration
+
+
+
+
+
+
+ Preparing editor...
+
+
+ )}
+
+ ) : (
+
+
+
+
+
+
No file selected
+
Choose a file from the explorer to start editing
+
+
+ )}
+
+
+
+ {/* Commit Panel */}
+ {isCommitPanelOpen && (
+
setIsCommitPanelOpen(false)}
+ />
+ )}
+
+ {/* Draft Restore Modal */}
+ {showDraftModal && pendingDraft && (
+
+
+
+ Unsaved Changes Found
+
+
+ You have unsaved changes for this file from{" "}
+
+ {new Date(pendingDraft.timestamp).toLocaleString()}
+
+ . Would you like to restore them?
+
+
+ {
+ // remove the draft
+ const draftKey = `${repoOwner}/${repoName}/${pendingDraft.filePath}`;
+ const newDrafts = { ...drafts };
+ delete newDrafts[draftKey];
+ setDrafts(newDrafts);
+ setShowDraftModal(false);
+ setPendingDraft(null);
+ }}
+ className="px-4 py-2 text-sm text-earth-300 border border-earth-200 rounded-md hover:bg-earth-50 transition-colors"
+ >
+ Discard Changes
+
+ {
+ // Restore the draft
+ setFileContent(pendingDraft.content);
+ setHasUnsavedChanges(true);
+ setShowDraftModal(false);
+ setPendingDraft(null);
+ }}
+ className="px-4 py-2 text-sm bg-earth-200 text-white rounded-md hover:bg-earth-400 transition-colors"
+ >
+ Restore Changes
+
+
+
+
+ )}
+
+ );
};
export default MDXEditorToast;
diff --git a/src/components/dashboard/MediaManager.tsx b/src/components/dashboard/MediaManager.tsx
new file mode 100644
index 0000000..15f921f
--- /dev/null
+++ b/src/components/dashboard/MediaManager.tsx
@@ -0,0 +1,398 @@
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import api from "@/lib/clients";
+import {
+ optimizeImage,
+ blobToBase64,
+ generateImageFilename,
+ isImageFile,
+ formatFileSize,
+ getOptimalFormat,
+} from "@/utils/imageOptimization";
+
+interface MediaFile {
+ name: string;
+ path: string;
+ size: number;
+ sha: string;
+ rawUrl: string;
+ githubUrl: string;
+}
+
+interface MediaManagerProps {
+ projectId: string;
+ repoOwner: string;
+ repoName: string;
+ mediaPath?: string;
+ onImageSelect?: (imageUrl: string) => void;
+}
+
+const MediaManager: React.FC = ({
+ projectId,
+ repoOwner,
+ repoName,
+ mediaPath,
+ onImageSelect,
+}) => {
+ const [files, setFiles] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [selectedImage, setSelectedImage] = useState(null);
+ const [currentMediaPath, setCurrentMediaPath] = useState(
+ mediaPath
+ );
+ const fileInputRef = useRef(null);
+
+ // Fetch media files from the media path
+ const fetchMediaFiles = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const query = currentMediaPath ? `?path=${encodeURIComponent(currentMediaPath)}` : "";
+ const response = await (api.projects as any)[projectId].repo[
+ repoOwner
+ ][repoName].media.$get({ query });
+
+ if (!response.ok) {
+ throw new Error(`Failed to load media: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ setFiles(data.data.files || []);
+ if (data.data.path) {
+ setCurrentMediaPath(data.data.path);
+ }
+ } catch (err) {
+ console.error("Error loading media:", err);
+ setError(err instanceof Error ? err.message : "Failed to load media files");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [projectId, repoOwner, repoName, currentMediaPath]);
+
+ useEffect(() => {
+ fetchMediaFiles();
+ }, [fetchMediaFiles]);
+
+ // Drag and drop handlers
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(true);
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+ }, []);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }, []);
+
+ const handleDrop = useCallback(
+ async (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+
+ const droppedFiles = Array.from(e.dataTransfer.files).filter(isImageFile);
+ if (droppedFiles.length > 0) {
+ await uploadFiles(droppedFiles);
+ }
+ },
+ [projectId, repoOwner, repoName, currentMediaPath]
+ );
+
+ // File upload handler
+ const uploadFiles = async (filesToUpload: File[]) => {
+ if (!currentMediaPath) {
+ setError("No media path configured");
+ return;
+ }
+
+ setUploading(true);
+ setUploadProgress(0);
+
+ try {
+ const totalFiles = filesToUpload.length;
+ const uploadedFiles = [];
+
+ for (let i = 0; i < filesToUpload.length; i++) {
+ const file = filesToUpload[i];
+
+ // Optimize image
+ const format = getOptimalFormat(file);
+ const optimized = await optimizeImage(file, {
+ maxWidth: 1920,
+ maxHeight: 1080,
+ quality: 0.85,
+ format,
+ });
+
+ // Convert to base64
+ const base64Content = await blobToBase64(optimized);
+
+ // Generate filename
+ const filename = generateImageFilename(file.name);
+
+ uploadedFiles.push({
+ filename,
+ content: base64Content,
+ });
+
+ setUploadProgress(Math.round(((i + 1) / totalFiles) * 100));
+ }
+
+ // Upload to server
+ const response = await (api.projects as any)[projectId].repo[
+ repoOwner
+ ][repoName]["media/upload"].$post({
+ json: {
+ files: uploadedFiles,
+ mediaPath: currentMediaPath,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Upload failed: ${response.statusText}`);
+ }
+
+ // Refresh file list
+ await fetchMediaFiles();
+ } catch (err) {
+ console.error("Upload error:", err);
+ setError(err instanceof Error ? err.message : "Upload failed");
+ } finally {
+ setUploading(false);
+ setUploadProgress(0);
+ }
+ };
+
+ // Handle file input change
+ const handleFileInputChange = async (
+ e: React.ChangeEvent
+ ) => {
+ const selectedFiles = Array.from(e.target.files || []).filter(isImageFile);
+ if (selectedFiles.length > 0) {
+ await uploadFiles(selectedFiles);
+ }
+ // Reset input
+ e.target.value = "";
+ };
+
+ // Handle toolbar button click
+ const handleToolbarClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ // Handle image click
+ const handleImageClick = (file: MediaFile) => {
+ setSelectedImage(file);
+ if (onImageSelect) {
+ onImageSelect(file.rawUrl);
+ }
+ };
+
+ // Copy image URL to clipboard
+ const copyImageUrl = (url: string) => {
+ navigator.clipboard.writeText(url);
+ };
+
+ if (isLoading) {
+ return (
+
+ Loading media...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
{error}
+
+ Try again
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
Media Library
+
+
{files.length} images
+
+
+
+
+
+
+
+
+ {/* Upload Progress */}
+ {uploading && (
+
+
+ Uploading...
+ {uploadProgress}%
+
+
+
+ )}
+
+ {/* Drag Overlay */}
+ {isDragging && (
+
+
+
+
+
+
Drop images here
+
+
+ )}
+
+ {/* File Grid */}
+
+ {files.length === 0 ? (
+
+
+
+
+
No images yet
+
+ Drag and drop images here or click the + button
+
+
+ ) : (
+
+ {files.map((file) => (
+
handleImageClick(file)}
+ >
+
+
+ {/* Hover Overlay */}
+
+
{
+ e.stopPropagation();
+ copyImageUrl(file.rawUrl);
+ }}
+ className="p-2 bg-white rounded-full text-earth-400 hover:text-earth-500"
+ title="Copy URL"
+ >
+
+
+
+
+
+
+ {/* Filename on hover */}
+
+
{file.name}
+
+ {formatFileSize(file.size)}
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Hidden File Input */}
+
+
+ );
+};
+
+export default MediaManager;
diff --git a/src/components/dashboard/ProjectSettingsModal.tsx b/src/components/dashboard/ProjectSettingsModal.tsx
index 7cf3a33..f3cc1a9 100644
--- a/src/components/dashboard/ProjectSettingsModal.tsx
+++ b/src/components/dashboard/ProjectSettingsModal.tsx
@@ -1,234 +1,364 @@
-import React, { useState } from "react";
+import React, { useState, useEffect } from "react";
import api from "@/lib/clients";
import Collaborators from "./Collaborators";
+interface DirectoryConfig {
+ path: string;
+ schema?: Record;
+ naming_convention?: string;
+ base_image_path?: string;
+}
+
interface ProjectSettingsModalProps {
- projectId: string;
+ projectId: string;
}
export default function ProjectSettingsModal({
- projectId,
+ projectId,
}: ProjectSettingsModalProps) {
- const [isOpen, setIsOpen] = useState(false);
- const [loading, setLoading] = useState(false);
- const [saving, setSaving] = useState(false);
- const [allowedDirs, setAllowedDirs] = useState("");
- const [settings, setSettings] = useState(null);
- const [activeTab, setActiveTab] = useState<"general" | "collaborators">("general");
-
- const fetchSettings = async () => {
- setLoading(true);
- try {
- const res = await (api.projects as any)[projectId].settings.$get();
- if (res.ok) {
- const data = await res.json();
- setSettings(data.data);
- const dirs = JSON.parse(data.data.public_directories || "[]");
- setAllowedDirs(dirs.join("\n"));
- }
- } catch (e) {
- console.error("Failed to fetch settings", e);
- } finally {
- setLoading(false);
- }
- };
-
- const handleOpen = () => {
- setIsOpen(true);
- setActiveTab("general");
- fetchSettings();
- };
-
- const handleSave = async () => {
- setSaving(true);
- try {
- const dirs = allowedDirs
- .split("\n")
- .map((d) => d.trim())
- .filter((d) => d);
-
- const payload = {
- public_directories: JSON.stringify(dirs),
- allow_file_creation: settings?.allow_file_creation ?? false,
- allow_file_editing: settings?.allow_file_editing ?? true,
- allow_file_deletion: settings?.allow_file_deletion ?? false,
- require_approval: settings?.require_approval ?? true,
- auto_merge: settings?.auto_merge ?? false,
- max_file_size: settings?.max_file_size ?? 1048576,
- allowed_extensions:
- settings?.allowed_extensions ??
- JSON.stringify([".md"]),
- collaborator_message: settings?.collaborator_message ?? "",
- };
-
- const res = await (api.projects as any)[projectId].settings.$put({
- json: payload,
- });
-
- if (res.ok) {
- setIsOpen(false);
- window.dispatchEvent(new Event("project-settings-updated"));
- } else {
- alert("Failed to save settings");
- }
- } catch (e) {
- console.error("Failed to save settings", e);
- alert("An error occurred while saving");
- } finally {
- setSaving(false);
- }
- };
-
- return (
- <>
-
-
-
-
-
- Settings
-
-
- {isOpen && (
-
-
-
-
- Project Settings
-
-
setIsOpen(false)}
- className="text-earth-300 hover:text-earth-500"
- >
-
-
-
-
-
-
- {/* Tabs */}
-
- setActiveTab("general")}
- >
- General
-
- setActiveTab("collaborators")}
- >
- Collaborators
-
-
-
- {loading ? (
-
- ) : (
-
- {activeTab === "general" && (
-
-
-
- Allowed Directories
-
-
- Enter one directory path per line (e.g.,{" "}
- content/blog). Only files in these
- directories will be accessible.
-
-
-
-
-
setIsOpen(false)}
- className="px-4 py-2 text-sm text-earth-400 border border-earth-200 rounded-md hover:bg-earth-50 transition-colors"
- >
- Cancel
-
-
- {saving && (
-
-
-
-
- )}
- Save Changes
-
-
-
- )}
-
- {activeTab === "collaborators" && (
-
- )}
-
- )}
-
-
- )}
- >
- );
+ const [isOpen, setIsOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [allowedDirs, setAllowedDirs] = useState([]);
+ const [rawAllowedText, setRawAllowedText] = useState("");
+ const [isAdvanced, setIsAdvanced] = useState(false);
+ const [settings, setSettings] = useState(null);
+ const [activeTab, setActiveTab] = useState<"general" | "collaborators">(
+ "general",
+ );
+ const [baseImagePath, setBaseImagePath] = useState("");
+
+ const fetchSettings = async () => {
+ try {
+ const res = await (api.projects as any)[projectId].settings.$get();
+ if (res.ok) {
+ const data = await res.json();
+ setSettings(data.data);
+ const dirs = data.data.public_directories || [];
+ setAllowedDirs(dirs);
+
+ const simpleList = dirs
+ .map((d: any) => (typeof d === "string" ? d : d.path))
+ .join("\n");
+ setRawAllowedText(simpleList);
+
+ const firstWithImagePath = dirs.find((d: any) => d.base_image_path);
+ if (firstWithImagePath?.base_image_path) {
+ setBaseImagePath(firstWithImagePath.base_image_path);
+ }
+ } else {
+ const errData = await res.json().catch(() => ({}));
+ console.error(
+ "Failed to fetch settings:",
+ errData.message || res.statusText,
+ );
+ }
+ } catch (e) {
+ console.error("Failed to fetch settings", e);
+ }
+ };
+
+ const handleOpen = () => {
+ setIsOpen(true);
+ setLoading(true);
+ fetchSettings().finally(() => setLoading(false));
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ let finalDirs: DirectoryConfig[] = [];
+
+ if (isAdvanced) {
+ try {
+ finalDirs = JSON.parse(rawAllowedText);
+ if (!Array.isArray(finalDirs)) throw new Error("Must be an array");
+ } catch (e) {
+ alert(
+ "Invalid JSON in advanced configuration. Please check your syntax.",
+ );
+ setSaving(false);
+ return;
+ }
+ } else {
+ // Simple view: parse paths and add base_image_path to each directory config
+ const paths = rawAllowedText
+ .split("\n")
+ .map((d) => d.trim())
+ .filter((d) => d);
+
+ finalDirs = paths.map((path) => ({
+ path,
+ base_image_path: baseImagePath || undefined,
+ }));
+ }
+
+ // Exclude DB timestamps from payload to avoid serialization issues
+ const { created_at, updated_at, ...settingsWithoutTimestamps } =
+ settings || {};
+ const payload = settings
+ ? {
+ ...settingsWithoutTimestamps,
+ public_directories: finalDirs,
+ }
+ : {
+ public_directories: finalDirs,
+ };
+
+ const res = await (api.projects as any)[projectId].settings.$put({
+ json: payload,
+ });
+
+ if (res.ok) {
+ setIsOpen(false);
+ window.dispatchEvent(new Event("project-settings-updated"));
+ } else {
+ const errData = await res.json().catch(() => ({}));
+ alert(
+ `Failed to save settings: ${errData.message || res.statusText || "Unknown error"}`,
+ );
+ }
+ } catch (e) {
+ console.error("Failed to save settings", e);
+ alert("An error occurred while saving");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+ Settings
+
+
+ {isOpen && (
+
+
+
+
+ Project Settings
+
+
setIsOpen(false)}
+ className="text-earth-300 hover:text-earth-500"
+ >
+
+
+
+
+
+
+ {/* Tabs */}
+
+ setActiveTab("general")}
+ >
+ General
+
+ setActiveTab("collaborators")}
+ >
+ Collaborators
+
+
+
+
+ {activeTab === "general" && (
+
+ {loading ? (
+
+
+
+ Loading general settings...
+
+
+ ) : (
+ <>
+
+
+
+ Allowed Directories
+
+ {
+ if (!isAdvanced) {
+ // Switch to advanced: show full JSON
+ const dirsWithImagePath = allowedDirs.map(
+ (d: any) => {
+ const config: DirectoryConfig = {
+ path: typeof d === "string" ? d : d.path,
+ };
+ if (baseImagePath)
+ config.base_image_path = baseImagePath;
+ if (d.schema) config.schema = d.schema;
+ if (d.naming_convention)
+ config.naming_convention =
+ d.naming_convention;
+ return config;
+ },
+ );
+ setRawAllowedText(
+ JSON.stringify(dirsWithImagePath, null, 2),
+ );
+ } else {
+ // Switch to simple: path list
+ const simpleList = allowedDirs
+ .map((d: any) =>
+ typeof d === "string" ? d : d.path,
+ )
+ .join("\n");
+ setRawAllowedText(simpleList);
+ }
+ setIsAdvanced(!isAdvanced);
+ }}
+ className="text-xs font-semibold text-earth-300 hover:text-earth-500 bg-earth-50 px-2 py-1 rounded border border-earth-100 transition-colors"
+ >
+ {isAdvanced
+ ? "Switch to Simple View"
+ : "Advanced Configuration (JSON)"}
+
+
+
+ {isAdvanced
+ ? "Edit the full configuration object including naming conventions, schemas, and base image paths for each directory."
+ : "Enter one directory path per line (e.g., content/blog)."}
+
+
+
+ {/* Base Image Path - only show in simple mode */}
+ {!isAdvanced && (
+
+
+ Base Image Path
+
+
+ Default path where images are stored. This will be
+ applied to all directories.
+
+
setBaseImagePath(e.target.value)}
+ placeholder="e.g., public/images or /assets/img"
+ className="w-full px-3 py-2 border border-earth-200 rounded-md focus:outline-none focus:ring-2 focus:ring-earth-400 focus:border-transparent text-sm"
+ />
+
+ This path will be used as the base for image
+ references in your content.
+
+
+ )}
+
+
+
setIsOpen(false)}
+ className="px-4 py-2 text-sm text-earth-400 border border-earth-200 rounded-md hover:bg-earth-50 transition-colors"
+ >
+ Cancel
+
+
+ {saving && (
+
+
+
+
+ )}
+ Save Changes
+
+
+ >
+ )}
+
+ )}
+
+ {activeTab === "collaborators" && (
+
+
+
+ )}
+
+
+
+ )}
+ >
+ );
}
diff --git a/src/components/dashboard/ProjectsList.tsx b/src/components/dashboard/ProjectsList.tsx
new file mode 100644
index 0000000..aa4dae2
--- /dev/null
+++ b/src/components/dashboard/ProjectsList.tsx
@@ -0,0 +1,149 @@
+import React, { useEffect, useState } from 'react';
+import { userProjectsSWR } from '@/utils/cachedFn';
+
+interface Project {
+ id: string;
+ name: string;
+ description: string;
+ github_repo_link: string;
+ org_id: string;
+}
+
+interface ProjectsListProps {
+ userId: string;
+ currentOrgId: string;
+}
+
+const ProjectsList: React.FC = ({ userId, currentOrgId }) => {
+ const [projects, setProjects] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ const fetchProjects = async () => {
+ setIsLoading(true);
+ try {
+ const response = await userProjectsSWR.fetch(userId);
+ if (isMounted) {
+ const allProjects = response?.data || [];
+ const orgProjects = allProjects.filter((p: Project) => p.org_id === currentOrgId);
+ setProjects(orgProjects);
+ setError(null);
+ }
+ } catch (err) {
+ if (isMounted) {
+ console.error('Error fetching projects:', err);
+ setError('Failed to load projects');
+ }
+ } finally {
+ if (isMounted) {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ fetchProjects();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [userId, currentOrgId]);
+
+ if (isLoading) {
+ return (
+
+ {[...Array(3)].map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
{error}
+
window.location.reload()}
+ className="mt-2 text-earth-400 hover:underline text-sm"
+ >
+ Try again
+
+
+ );
+ }
+
+ if (projects.length === 0) {
+ return (
+
+
+
+
+
No projects yet
+
+ Create your first project to get started
+
+
+ );
+ }
+
+ return (
+
+ {projects.map((project) => (
+
+
+
+
+ {project.name || "Unnamed Project"}
+
+
+ {project.description || "No description"}
+
+
+
+
+
+ ))}
+
+ );
+};
+
+export default ProjectsList;
diff --git a/src/components/dashboard/RecentActivities.tsx b/src/components/dashboard/RecentActivities.tsx
new file mode 100644
index 0000000..627f3f3
--- /dev/null
+++ b/src/components/dashboard/RecentActivities.tsx
@@ -0,0 +1,245 @@
+import { useCallback, useEffect, useState } from "react";
+import { actions } from "astro:actions";
+
+interface ProjectActivity {
+ id: string;
+ action_type: string;
+ file_path: string;
+ file_name: string;
+ contributor_name?: string;
+ created_at: string;
+}
+
+interface RecentActivitiesProps {
+ projectIds: string[];
+ initialLimit?: number;
+}
+
+const RecentActivities: React.FC = ({
+ projectIds,
+ initialLimit = 20,
+}) => {
+ const [activities, setActivities] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [hasMore, setHasMore] = useState(false);
+ const [page, setPage] = useState(1);
+ const [error, setError] = useState(null);
+
+ const fetchActivities = useCallback(async () => {
+ if (projectIds.length === 0) {
+ setIsLoading(false);
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ // Fetch activities for each project with pagination
+ const activityPromises = projectIds.map((projectId) =>
+ actions.projectsActions.getProjectActivity({
+ projectId,
+ page: 1,
+ limit: Math.ceil(initialLimit / projectIds.length),
+ }),
+ );
+
+ const activityResults = await Promise.all(activityPromises);
+ const allActivities = activityResults
+ .map((res) => res.data || [])
+ .flat()
+ .sort(
+ (a, b) =>
+ new Date(b.created_at).getTime() -
+ new Date(a.created_at).getTime(),
+ )
+ .slice(0, initialLimit);
+
+ setActivities(allActivities);
+ setHasMore(allActivities.length >= initialLimit);
+ } catch (err) {
+ console.error("Error fetching activities:", err);
+ setError("Failed to load activities");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [projectIds, initialLimit]);
+
+ const loadMore = useCallback(async () => {
+ if (projectIds.length === 0) return;
+
+ const nextPage = page + 1;
+ setPage(nextPage);
+
+ try {
+ const activityPromises = projectIds.map((projectId) =>
+ actions.projectsActions.getProjectActivity({
+ projectId,
+ page: nextPage,
+ limit: Math.ceil(initialLimit / projectIds.length),
+ }),
+ );
+
+ const activityResults = await Promise.all(activityPromises);
+ const newActivities = activityResults
+ .map((res) => res.data || [])
+ .flat()
+ .sort(
+ (a, b) =>
+ new Date(b.created_at).getTime() -
+ new Date(a.created_at).getTime(),
+ );
+
+ if (newActivities.length === 0) {
+ setHasMore(false);
+ } else {
+ setActivities((prev) => {
+ const combined = [...prev, ...newActivities];
+ // Remove duplicates and sort
+ const unique = combined.filter(
+ (activity, index, self) =>
+ index === self.findIndex((a) => a.id === activity.id),
+ );
+ return unique.sort(
+ (a, b) =>
+ new Date(b.created_at).getTime() -
+ new Date(a.created_at).getTime(),
+ );
+ });
+ }
+ } catch (err) {
+ console.error("Error loading more activities:", err);
+ }
+ }, [projectIds, page, initialLimit]);
+
+ useEffect(() => {
+ fetchActivities();
+ }, [fetchActivities]);
+
+ const getRelativeTime = (dateString: string): string => {
+ const date = new Date(dateString);
+ const now = new Date();
+ const diff = Math.round((now.getTime() - date.getTime()) / 1000);
+
+ if (diff < 60) return `${diff}s ago`;
+ if (diff < 3600) return `${Math.round(diff / 60)}mins ago`;
+ if (diff < 86400) return `${Math.round(diff / 3600)}hrs ago`;
+ return `${Math.round(diff / 86400)}days ago`;
+ };
+
+ if (isLoading) {
+ return (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
{error}
+
+ Try again
+
+
+ );
+ }
+
+ if (activities.length === 0) {
+ return (
+
+
+
+
+
+
No recent activity
+
+ Recent project activities will appear here.
+
+
+ );
+ }
+
+ return (
+
+ {activities.map((activity) => (
+
+
+
+
+
{activity.action_type}
+
on
+
+ {activity.file_name}
+
+ {activity.contributor_name && (
+
+ by
+
+ {activity.contributor_name}
+
+
+ )}
+
+
+ {getRelativeTime(activity.created_at)}
+
+
+
+ ))}
+
+ {hasMore && (
+
+
+ Load More
+
+
+ )}
+
+ );
+};
+
+export default RecentActivities;
diff --git a/src/components/dashboard/Sidebar.astro b/src/components/dashboard/Sidebar.astro
index 6ee586a..623455e 100644
--- a/src/components/dashboard/Sidebar.astro
+++ b/src/components/dashboard/Sidebar.astro
@@ -13,10 +13,13 @@ const { organizations = [], currentOrg = null } = Astro.props;
dropdownOpen: window.location.pathname === '/dashboard/orgs/new',
isMobileMenuOpen: false,
isCollapsed: false,
+ isSwitching: false,
toggleCollapse() {
this.isCollapsed = !this.isCollapsed;
},
handleOrgSwitch(org) {
+ if (this.isActiveOrg(org.id)) return;
+ this.isSwitching = true;
this.selectedOrg = org;
this.dropdownOpen = false;
// Navigate to current page with org parameter for server-side rendering
@@ -69,37 +72,52 @@ const { organizations = [], currentOrg = null } = Astro.props;
class="h-full bg-white border-r border-earth-100 flex flex-col transition-all duration-300 md:relative fixed top-0 left-0 bottom-0 z-40 md:z-auto shadow-lg md:shadow-none overflow-hidden"
:class="{ 'md:w-64': !isCollapsed, 'md:w-16': isCollapsed, 'translate-x-0': isMobileMenuOpen, '-translate-x-full md:translate-x-0': !isMobileMenuOpen }"
>
-
-
Dashboard
-
-
+
-
+
-
Main
+
Main
-
-
+
+
- Overview
+ Overview
+
+
+
+
+
+
+
+ Projects
-
-
-
+
+
+
-
Organizations
+
Organizations
-
+
@@ -112,8 +130,14 @@ const { organizations = [], currentOrg = null } = Astro.props;
:class={` isActiveOrg('${org.id}') ? 'bg-earth-50' : ''`}
x-on:click={`handleOrgSwitch(${JSON.stringify(org)})`}
>
-
- {org.name[0]}
+
+
{org.name[0]}
+
+
+
+
+
+
{org.name}
@@ -133,29 +157,19 @@ const { organizations = [], currentOrg = null } = Astro.props;
-
-
- window.history.pushState({}, '', '/dashboard/project/new')" class="flex w-full items-center px-3 py-2 text-earth-300 hover:bg-earth-50 transition-colors">
-
- +
-
- Create Project
-
-
-
-
+
-
Settings
+
Settings
-
-
+
+
- Profile
+ Profile
0) {
+ currentOrg = userOrgs.find((org) => org.id.toString() === orgIdParam);
+ if (currentOrg) {
+ currentOrgIdState.set(currentOrg.id);
+ }
+}
+
+if (!currentOrg && !orgIdParam) {
+ const currentOrgId = currentOrgIdState.get();
+ if (currentOrgId && Array.isArray(userOrgs)) {
+ currentOrg = userOrgs.find((org) => org.id === currentOrgId);
+ }
+}
+
+if (!currentOrg && Array.isArray(userOrgs) && userOrgs.length > 0) {
+ currentOrg = userOrgs[0];
+ currentOrgIdState.set(currentOrg.id);
+
+ return Astro.redirect("/dashboard/projects?org=" + currentOrg.id);
+}
+
+const userName = currentUser?.username;
+
+const needsGitHubInstallation = !currentOrg?.installationId;
+
+let projects: any[] = [];
+if (currentOrg && userId) {
+ try {
+ const projectsResponse = await userProjects.fetch(userId);
+ const projectsData = projectsResponse?.data;
+ if (projectsData) {
+ projects = projectsData.filter(
+ (project: { org_id: any; }) => project.org_id === currentOrg.id,
+ );
+ }
+ } catch (error) {
+ console.error("Error fetching projects:", error);
+ }
+}
+---
+
+
+
+ {
+ userOrgs && userOrgs.length > 0 ? (
+ <>
+
+
+ {/* Header */}
+
+
+
+
+ All Projects
+
+
+ Manage all your projects in{" "}
+
+ {currentOrg?.name || "your organization"}
+
+
+
+
+
+
+
+ New Project
+
+
+
+
+ {/* Projects List */}
+
+ {projects.length > 0 ? (
+
+ {projects.map((project) => (
+
+
+
+
+ {project.name || "Unnamed Project"}
+
+
+ {project.description || "No description"}
+
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+
+ No projects yet
+
+
+ Create your first project to get started
+
+
+ )}
+
+ >
+ ) : (
+
+
+
+
+ Welcome to Collab-X!
+
+
+ You don't have any organizations yet. Create one to
+ get started with your projects and team
+ collaboration.
+
+
+
+
+
+ Create Organization
+
+
+
+ )
+ }
+
+
diff --git a/src/utils/cache.ts b/src/utils/cache.ts
index ce5174d..d7b4c1d 100644
--- a/src/utils/cache.ts
+++ b/src/utils/cache.ts
@@ -171,6 +171,157 @@ export function createCachedFetcher(
};
}
+// In-memory cache for SWR (client-side only)
+type SWRCacheEntry = {
+ data: T;
+ timestamp: number;
+ isStale: boolean;
+};
+
+const swrMemoryCache = new Map>();
+
+/**
+ * Creates a Stale-While-Revalidate fetcher for client-side caching.
+ * Returns cached data immediately if available, then refreshes in background.
+ *
+ * @example
+ * ```ts
+ * const projectsSWR = createSWRFetcher({
+ * key: 'user-projects',
+ * fetcher: async (userId) => {
+ * const response = await fetch(`/api/projects?userId=${userId}`);
+ * return response.json();
+ * },
+ * maxAge: 30000, // Consider fresh for 30 seconds
+ * staleTime: 300000, // Serve stale for 5 minutes
+ * });
+ *
+ * // Use in component
+ * const projects = await projectsSWR.fetch(userId);
+ * ```
+ */
+export interface SWROptions {
+ key: string;
+ fetcher: (param: P) => Promise;
+ maxAge?: number; // Time in ms to consider data fresh (default: 30s)
+ staleTime?: number; // Time in ms to serve stale data while revalidating (default: 5min)
+}
+
+export function createSWRFetcher(
+ options: SWROptions,
+) {
+ const { key, fetcher, maxAge = 30000, staleTime = 300000 } = options;
+
+ const getCacheKey = (param: P) => `${key}:${param}`;
+
+ return {
+ /**
+ * Fetches data using SWR pattern:
+ * - Returns cached data immediately if not expired
+ * - Returns stale data and revalidates in background if within staleTime
+ * - Fetches fresh data if cache is expired
+ */
+ async fetch(param: P): Promise {
+ const cacheKey = getCacheKey(param);
+ const cached = swrMemoryCache.get(cacheKey) as
+ | SWRCacheEntry
+ | undefined;
+ const now = Date.now();
+
+ if (cached) {
+ const age = now - cached.timestamp;
+
+ // Data is fresh - return immediately
+ if (age < maxAge) {
+ return cached.data;
+ }
+
+ // Data is stale but within staleTime - return stale data and revalidate
+ if (age < maxAge + staleTime) {
+ // Trigger background revalidation
+ this.revalidate(param).catch((err) => {
+ console.warn(`[SWR] Background revalidation failed for ${cacheKey}:`, err);
+ });
+ return cached.data;
+ }
+
+ // Data is expired - remove from cache and fetch fresh
+ swrMemoryCache.delete(cacheKey);
+ }
+
+ // No cache or expired - fetch fresh data
+ return this.revalidate(param);
+ },
+
+ /**
+ * Force revalidation - fetches fresh data and updates cache
+ */
+ async revalidate(param: P): Promise {
+ const cacheKey = getCacheKey(param);
+
+ try {
+ const freshData = await fetcher(param);
+
+ // Update cache
+ swrMemoryCache.set(cacheKey, {
+ data: freshData,
+ timestamp: Date.now(),
+ isStale: false,
+ });
+
+ return freshData;
+ } catch (error) {
+ // On error, try to return stale data if available
+ const cached = swrMemoryCache.get(cacheKey) as SWRCacheEntry | undefined;
+ if (cached) {
+ console.warn(`[SWR] Fetch failed, returning stale data for ${cacheKey}`);
+ return cached.data;
+ }
+ throw error;
+ }
+ },
+
+ /**
+ * Gets cached data without triggering revalidation
+ */
+ peek(param: P): T | undefined {
+ const cacheKey = getCacheKey(param);
+ const cached = swrMemoryCache.get(cacheKey) as SWRCacheEntry | undefined;
+ return cached?.data;
+ },
+
+ /**
+ * Invalidates cache for a specific param
+ */
+ invalidate(param: P): void {
+ const cacheKey = getCacheKey(param);
+ swrMemoryCache.delete(cacheKey);
+ },
+
+ /**
+ * Invalidates all cache entries for this key
+ */
+ invalidateAll(): void {
+ const prefix = `${key}:`;
+ for (const cacheKey of swrMemoryCache.keys()) {
+ if (cacheKey.startsWith(prefix)) {
+ swrMemoryCache.delete(cacheKey);
+ }
+ }
+ },
+
+ /**
+ * Gets the age of cached data in milliseconds
+ */
+ getAge(param: P): number | null {
+ const cacheKey = getCacheKey(param);
+ const cached = swrMemoryCache.get(cacheKey) as SWRCacheEntry | undefined;
+ if (!cached) return null;
+ return Date.now() - cached.timestamp;
+ },
+ };
+}
+
/**
* Usage Examples:
*
diff --git a/src/utils/cachedFn.ts b/src/utils/cachedFn.ts
index 1e0a7c7..22967f7 100644
--- a/src/utils/cachedFn.ts
+++ b/src/utils/cachedFn.ts
@@ -1,6 +1,7 @@
import api from "../lib/clients";
-import { createCachedFetcher } from "./cache";
+import { createCachedFetcher, createSWRFetcher } from "./cache";
+// Server-side cached fetchers (Redis-backed)
export const userProjects = createCachedFetcher(
"userProjects",
async (userId: string) => {
@@ -33,4 +34,43 @@ export const userOrgs = createCachedFetcher(
return { data: [] };
}
},
-);
\ No newline at end of file
+);
+
+// Client-side SWR fetchers (memory-backed with stale-while-revalidate)
+export const userProjectsSWR = createSWRFetcher({
+ key: "user-projects-swr",
+ fetcher: async (userId: string) => {
+ try {
+ const response = await api.projects.$get({
+ query: { userId: userId },
+ });
+ if (response.status === 404) return { data: [] };
+ if (!response.ok) throw new Error("Failed to fetch projects");
+ return response.json();
+ } catch (error) {
+ console.error("[SWR] Error fetching projects:", error);
+ return { data: [] };
+ }
+ },
+ maxAge: 30000, // 30 seconds fresh
+ staleTime: 300000, // 5 minutes stale
+});
+
+export const userOrgsSWR = createSWRFetcher({
+ key: "user-orgs-swr",
+ fetcher: async (userId: string) => {
+ try {
+ const response = await api.orgs.$get({
+ query: { userId: userId },
+ });
+ if (response.status === 404) return { data: [] };
+ if (!response.ok) throw new Error("Failed to fetch organizations");
+ return response.json();
+ } catch (error) {
+ console.error("[SWR] Error fetching organizations:", error);
+ return { data: [] };
+ }
+ },
+ maxAge: 60000, // 1 minute fresh
+ staleTime: 600000, // 10 minutes stale
+});
\ No newline at end of file
diff --git a/src/utils/imageOptimization.ts b/src/utils/imageOptimization.ts
new file mode 100644
index 0000000..9c36980
--- /dev/null
+++ b/src/utils/imageOptimization.ts
@@ -0,0 +1,195 @@
+/**
+ * Image optimization utility for compressing and resizing images before upload
+ */
+
+export interface ImageOptimizationOptions {
+ maxWidth?: number;
+ maxHeight?: number;
+ quality?: number; // 0-1
+ format?: "image/jpeg" | "image/png" | "image/webp";
+}
+
+const DEFAULT_OPTIONS: ImageOptimizationOptions = {
+ maxWidth: 1920,
+ maxHeight: 1080,
+ quality: 0.85,
+ format: "image/jpeg",
+};
+
+/**
+ * Compress and resize an image file
+ */
+export async function optimizeImage(
+ file: File,
+ options: ImageOptimizationOptions = {},
+): Promise {
+ const opts = { ...DEFAULT_OPTIONS, ...options };
+
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ const url = URL.createObjectURL(file);
+
+ img.onload = () => {
+ URL.revokeObjectURL(url);
+
+ // Calculate new dimensions while maintaining aspect ratio
+ let { width, height } = calculateDimensions(
+ img.width,
+ img.height,
+ opts.maxWidth!,
+ opts.maxHeight!,
+ );
+
+ // Create canvas and draw resized image
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ reject(new Error("Failed to get canvas context"));
+ return;
+ }
+
+ // Use better quality scaling
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = "high";
+
+ ctx.drawImage(img, 0, 0, width, height);
+
+ // Convert to blob with compression
+ canvas.toBlob(
+ (blob) => {
+ if (blob) {
+ resolve(blob);
+ } else {
+ reject(new Error("Failed to create blob from canvas"));
+ }
+ },
+ opts.format,
+ opts.quality,
+ );
+ };
+
+ img.onerror = () => {
+ URL.revokeObjectURL(url);
+ reject(new Error("Failed to load image"));
+ };
+
+ img.src = url;
+ });
+}
+
+/**
+ * Calculate new dimensions maintaining aspect ratio
+ */
+function calculateDimensions(
+ origWidth: number,
+ origHeight: number,
+ maxWidth: number,
+ maxHeight: number,
+): { width: number; height: number } {
+ let width = origWidth;
+ let height = origHeight;
+
+ // Scale down if larger than max dimensions
+ if (width > maxWidth) {
+ height = Math.round((height * maxWidth) / width);
+ width = maxWidth;
+ }
+
+ if (height > maxHeight) {
+ width = Math.round((width * maxHeight) / height);
+ height = maxHeight;
+ }
+
+ return { width, height };
+}
+
+/**
+ * Convert a Blob to Base64 string
+ */
+export function blobToBase64(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => {
+ const base64 = reader.result as string;
+ // Remove data URL prefix
+ resolve(base64.split(",")[1]);
+ };
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+}
+
+/**
+ * Get optimal format based on file type
+ */
+export function getOptimalFormat(
+ file: File,
+): "image/jpeg" | "image/png" | "image/webp" {
+ if (file.type === "image/jpeg" || file.type === "image/jpg") {
+ // Check if browser supports WebP
+ const canvas = document.createElement("canvas");
+ if (canvas.toDataURL("image/webp").indexOf("data:image/webp") === 0) {
+ return "image/webp";
+ }
+ return "image/jpeg";
+ }
+
+ if (file.type === "image/png") {
+ return "image/png";
+ }
+
+ return "image/jpeg";
+}
+
+/**
+ * Generate a unique filename for uploaded image
+ */
+export function generateImageFilename(originalName: string): string {
+ const timestamp = Date.now();
+ const random = Math.random().toString(36).substring(2, 8);
+ const extension = originalName.split(".").pop()?.toLowerCase() || "jpg";
+
+ const finalExtension =
+ extension === "jpg" || extension === "jpeg" ? "webp" : extension;
+
+ return `${timestamp}-${random}.${finalExtension}`;
+}
+
+/**
+ * Check if file is an image
+ */
+export function isImageFile(file: File): boolean {
+ return file.type.startsWith("image/");
+}
+
+/**
+ * Get file size in human readable format
+ */
+export function formatFileSize(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+}
+
+/**
+ * Process multiple images in parallel
+ */
+export async function processImages(
+ files: File[],
+ options?: ImageOptimizationOptions,
+): Promise<{ file: File; optimized: Blob; originalName: string }[]> {
+ const results = await Promise.all(
+ files.map(async (file) => {
+ const format = getOptimalFormat(file);
+ const optimized = await optimizeImage(file, { ...options, format });
+ return { file, optimized, originalName: file.name };
+ }),
+ );
+
+ return results;
+}