Unverified Commit ac077ac3 authored by memoclaw's avatar memoclaw Committed by GitHub

refactor(web): improve MemoView and MemoEditor maintainability (#5754)

Co-authored-by: 's avatarmemoclaw <265580040+memoclaw@users.noreply.github.com>
Co-authored-by: 's avatarCopilot <223556219+Copilot@users.noreply.github.com>
parent 7b4f3a9f
...@@ -52,24 +52,33 @@ const InsertMenu = (props: InsertMenuProps) => { ...@@ -52,24 +52,33 @@ const InsertMenu = (props: InsertMenuProps) => {
}); });
const location = useLocation(props.location); const location = useLocation(props.location);
const {
state: locationState,
locationInitialized,
handlePositionChange: handleLocationPositionChange,
getLocation,
reset: locationReset,
updateCoordinate,
setPlaceholder,
} = location;
const [debouncedPosition, setDebouncedPosition] = useState<LatLng | undefined>(undefined); const [debouncedPosition, setDebouncedPosition] = useState<LatLng | undefined>(undefined);
useDebounce( useDebounce(
() => { () => {
setDebouncedPosition(location.state.position); setDebouncedPosition(locationState.position);
}, },
1000, 1000,
[location.state.position], [locationState.position],
); );
const { data: displayName } = useReverseGeocoding(debouncedPosition?.lat, debouncedPosition?.lng); const { data: displayName } = useReverseGeocoding(debouncedPosition?.lat, debouncedPosition?.lng);
useEffect(() => { useEffect(() => {
if (displayName) { if (displayName) {
location.setPlaceholder(displayName); setPlaceholder(displayName);
} }
}, [displayName]); }, [displayName, setPlaceholder]);
const isUploading = selectingFlag || isUploadingProp; const isUploading = selectingFlag || isUploadingProp;
...@@ -79,11 +88,11 @@ const InsertMenu = (props: InsertMenuProps) => { ...@@ -79,11 +88,11 @@ const InsertMenu = (props: InsertMenuProps) => {
const handleLocationClick = useCallback(() => { const handleLocationClick = useCallback(() => {
setLocationDialogOpen(true); setLocationDialogOpen(true);
if (!initialLocation && !location.locationInitialized) { if (!initialLocation && !locationInitialized) {
if (navigator.geolocation) { if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => { (position) => {
location.handlePositionChange(new LatLng(position.coords.latitude, position.coords.longitude)); handleLocationPositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
}, },
(error) => { (error) => {
console.error("Geolocation error:", error); console.error("Geolocation error:", error);
...@@ -91,27 +100,20 @@ const InsertMenu = (props: InsertMenuProps) => { ...@@ -91,27 +100,20 @@ const InsertMenu = (props: InsertMenuProps) => {
); );
} }
} }
}, [initialLocation, location]); }, [initialLocation, locationInitialized, handleLocationPositionChange]);
const handleLocationConfirm = useCallback(() => { const handleLocationConfirm = useCallback(() => {
const newLocation = location.getLocation(); const newLocation = getLocation();
if (newLocation) { if (newLocation) {
onLocationChange(newLocation); onLocationChange(newLocation);
setLocationDialogOpen(false); setLocationDialogOpen(false);
} }
}, [location, onLocationChange]); }, [getLocation, onLocationChange]);
const handleLocationCancel = useCallback(() => { const handleLocationCancel = useCallback(() => {
location.reset(); locationReset();
setLocationDialogOpen(false); setLocationDialogOpen(false);
}, [location]); }, [locationReset]);
const handlePositionChange = useCallback(
(position: LatLng) => {
location.handlePositionChange(position);
},
[location],
);
const handleToggleFocusMode = useCallback(() => { const handleToggleFocusMode = useCallback(() => {
onToggleFocusMode?.(); onToggleFocusMode?.();
...@@ -200,11 +202,10 @@ const InsertMenu = (props: InsertMenuProps) => { ...@@ -200,11 +202,10 @@ const InsertMenu = (props: InsertMenuProps) => {
<LocationDialog <LocationDialog
open={locationDialogOpen} open={locationDialogOpen}
onOpenChange={setLocationDialogOpen} onOpenChange={setLocationDialogOpen}
state={location.state} state={locationState}
locationInitialized={location.locationInitialized} onPositionChange={handleLocationPositionChange}
onPositionChange={handlePositionChange} onUpdateCoordinate={updateCoordinate}
onUpdateCoordinate={location.updateCoordinate} onPlaceholderChange={setPlaceholder}
onPlaceholderChange={location.setPlaceholder}
onCancel={handleLocationCancel} onCancel={handleLocationCancel}
onConfirm={handleLocationConfirm} onConfirm={handleLocationConfirm}
/> />
......
...@@ -12,7 +12,6 @@ export const LocationDialog = ({ ...@@ -12,7 +12,6 @@ export const LocationDialog = ({
open, open,
onOpenChange, onOpenChange,
state, state,
locationInitialized: _locationInitialized,
onPositionChange, onPositionChange,
onUpdateCoordinate, onUpdateCoordinate,
onPlaceholderChange, onPlaceholderChange,
......
export const LOCALSTORAGE_DEBOUNCE_DELAY = 500;
export const FOCUS_MODE_STYLES = { export const FOCUS_MODE_STYLES = {
backdrop: "fixed inset-0 bg-black/20 backdrop-blur-sm z-40", backdrop: "fixed inset-0 bg-black/20 backdrop-blur-sm z-40",
container: { container: {
......
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import type { EditorRefActions } from "../Editor"; import type { EditorRefActions } from "../Editor";
interface UseKeyboardOptions { export const useKeyboard = (editorRef: React.RefObject<EditorRefActions | null>, onSave: () => void) => {
onSave: () => void; const onSaveRef = useRef(onSave);
} onSaveRef.current = onSave;
export const useKeyboard = (editorRef: React.RefObject<EditorRefActions | null>, options: UseKeyboardOptions) => {
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key !== "Enter") { if (!(event.metaKey || event.ctrlKey) || event.key !== "Enter") {
...@@ -24,10 +23,10 @@ export const useKeyboard = (editorRef: React.RefObject<EditorRefActions | null>, ...@@ -24,10 +23,10 @@ export const useKeyboard = (editorRef: React.RefObject<EditorRefActions | null>,
} }
event.preventDefault(); event.preventDefault();
options.onSave(); onSaveRef.current();
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [editorRef, options]); }, [editorRef]);
}; };
import { create } from "@bufbuild/protobuf"; import { create } from "@bufbuild/protobuf";
import { LatLng } from "leaflet"; import { LatLng } from "leaflet";
import { useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { Location, LocationSchema } from "@/types/proto/api/v1/memo_service_pb"; import { Location, LocationSchema } from "@/types/proto/api/v1/memo_service_pb";
import { LocationState } from "../types/insert-menu"; import { LocationState } from "../types/insert-menu";
export const useLocation = (initialLocation?: Location) => { export const useLocation = (initialLocation?: Location) => {
const [locationInitialized, setLocationInitialized] = useState(false); const [locationInitialized, setLocationInitialized] = useState(false);
const locationInitializedRef = useRef(locationInitialized);
locationInitializedRef.current = locationInitialized;
const [state, setState] = useState<LocationState>({ const [state, setState] = useState<LocationState>({
placeholder: initialLocation?.placeholder || "", placeholder: initialLocation?.placeholder || "",
position: initialLocation ? new LatLng(initialLocation.latitude, initialLocation.longitude) : undefined, position: initialLocation ? new LatLng(initialLocation.latitude, initialLocation.longitude) : undefined,
...@@ -13,34 +16,48 @@ export const useLocation = (initialLocation?: Location) => { ...@@ -13,34 +16,48 @@ export const useLocation = (initialLocation?: Location) => {
lngInput: initialLocation ? String(initialLocation.longitude) : "", lngInput: initialLocation ? String(initialLocation.longitude) : "",
}); });
const updatePosition = (position?: LatLng) => { // Ref to latest state so getLocation can be stable without closing over state.
const stateRef = useRef(state);
stateRef.current = state;
const updatePosition = useCallback((position?: LatLng) => {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
position, position,
latInput: position ? String(position.lat) : "", latInput: position ? String(position.lat) : "",
lngInput: position ? String(position.lng) : "", lngInput: position ? String(position.lng) : "",
})); }));
}; }, []);
const handlePositionChange = (position: LatLng) => { // Stable — reads locationInitialized via ref to avoid recreating on every change.
if (!locationInitialized) setLocationInitialized(true); const handlePositionChange = useCallback(
updatePosition(position); (position: LatLng) => {
}; if (!locationInitializedRef.current) setLocationInitialized(true);
updatePosition(position);
},
[updatePosition],
);
const updateCoordinate = (type: "lat" | "lng", value: string) => { // Stable — merges coordinate update into a single functional setState, avoiding closure over state.position.
setState((prev) => ({ ...prev, [type === "lat" ? "latInput" : "lngInput"]: value })); const updateCoordinate = useCallback((type: "lat" | "lng", value: string) => {
const num = parseFloat(value); const num = parseFloat(value);
const isValid = type === "lat" ? !isNaN(num) && num >= -90 && num <= 90 : !isNaN(num) && num >= -180 && num <= 180; const isValid = type === "lat" ? !isNaN(num) && num >= -90 && num <= 90 : !isNaN(num) && num >= -180 && num <= 180;
if (isValid && state.position) { setState((prev) => {
updatePosition(type === "lat" ? new LatLng(num, state.position.lng) : new LatLng(state.position.lat, num)); const next = { ...prev, [type === "lat" ? "latInput" : "lngInput"]: value };
} if (isValid && prev.position) {
}; const newPos = type === "lat" ? new LatLng(num, prev.position.lng) : new LatLng(prev.position.lat, num);
return { ...next, position: newPos, latInput: String(newPos.lat), lngInput: String(newPos.lng) };
}
return next;
});
}, []);
const setPlaceholder = (placeholder: string) => { // Stable reference — uses functional setState, no closure deps.
const setPlaceholder = useCallback((placeholder: string) => {
setState((prev) => ({ ...prev, placeholder })); setState((prev) => ({ ...prev, placeholder }));
}; }, []);
const reset = () => { const reset = useCallback(() => {
setState({ setState({
placeholder: "", placeholder: "",
position: undefined, position: undefined,
...@@ -48,26 +65,23 @@ export const useLocation = (initialLocation?: Location) => { ...@@ -48,26 +65,23 @@ export const useLocation = (initialLocation?: Location) => {
lngInput: "", lngInput: "",
}); });
setLocationInitialized(false); setLocationInitialized(false);
}; }, []);
const getLocation = (): Location | undefined => { // Stable — reads latest state via ref, no closure over state.
if (!state.position || !state.placeholder.trim()) { const getLocation = useCallback((): Location | undefined => {
const { position, placeholder } = stateRef.current;
if (!position || !placeholder.trim()) {
return undefined; return undefined;
} }
return create(LocationSchema, { return create(LocationSchema, {
latitude: state.position.lat, latitude: position.lat,
longitude: state.position.lng, longitude: position.lng,
placeholder: state.placeholder, placeholder,
}); });
}; }, []);
return { return useMemo(
state, () => ({ state, locationInitialized, handlePositionChange, updateCoordinate, setPlaceholder, reset, getLocation }),
locationInitialized, [state, locationInitialized, handlePositionChange, updateCoordinate, setPlaceholder, reset, getLocation],
handlePositionChange, );
updateCoordinate,
setPlaceholder,
reset,
getLocation,
};
}; };
...@@ -57,7 +57,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -57,7 +57,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
dispatch(actions.toggleFocusMode()); dispatch(actions.toggleFocusMode());
}; };
useKeyboard(editorRef, { onSave: handleSave }); useKeyboard(editorRef, handleSave);
async function handleSave() { async function handleSave() {
// Validate before saving // Validate before saving
...@@ -145,7 +145,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -145,7 +145,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
)} )}
{/* Editor content grows to fill available space in focus mode */} {/* Editor content grows to fill available space in focus mode */}
<EditorContent ref={editorRef} placeholder={placeholder} autoFocus={autoFocus} /> <EditorContent ref={editorRef} placeholder={placeholder} />
{/* Metadata and toolbar grouped together at bottom */} {/* Metadata and toolbar grouped together at bottom */}
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
......
...@@ -135,7 +135,6 @@ export const memoService = { ...@@ -135,7 +135,6 @@ export const memoService = {
ui: { ui: {
isFocusMode: false, isFocusMode: false,
isLoading: { saving: false, uploading: false, loading: false }, isLoading: { saving: false, uploading: false, loading: false },
isDragging: false,
isComposing: false, isComposing: false,
}, },
timestamps: { timestamps: {
......
...@@ -62,11 +62,6 @@ export const editorActions = { ...@@ -62,11 +62,6 @@ export const editorActions = {
payload: { key, value }, payload: { key, value },
}), }),
setDragging: (value: boolean): EditorAction => ({
type: "SET_DRAGGING",
payload: value,
}),
setComposing: (value: boolean): EditorAction => ({ setComposing: (value: boolean): EditorAction => ({
type: "SET_COMPOSING", type: "SET_COMPOSING",
payload: value, payload: value,
......
...@@ -101,15 +101,6 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS ...@@ -101,15 +101,6 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS
}, },
}; };
case "SET_DRAGGING":
return {
...state,
ui: {
...state.ui,
isDragging: action.payload,
},
};
case "SET_COMPOSING": case "SET_COMPOSING":
return { return {
...state, ...state,
......
...@@ -20,7 +20,6 @@ export interface EditorState { ...@@ -20,7 +20,6 @@ export interface EditorState {
uploading: boolean; uploading: boolean;
loading: boolean; loading: boolean;
}; };
isDragging: boolean;
isComposing: boolean; isComposing: boolean;
}; };
timestamps: { timestamps: {
...@@ -43,7 +42,6 @@ export type EditorAction = ...@@ -43,7 +42,6 @@ export type EditorAction =
| { type: "CLEAR_LOCAL_FILES" } | { type: "CLEAR_LOCAL_FILES" }
| { type: "TOGGLE_FOCUS_MODE" } | { type: "TOGGLE_FOCUS_MODE" }
| { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } } | { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } }
| { type: "SET_DRAGGING"; payload: boolean }
| { type: "SET_COMPOSING"; payload: boolean } | { type: "SET_COMPOSING"; payload: boolean }
| { type: "SET_TIMESTAMPS"; payload: Partial<EditorState["timestamps"]> } | { type: "SET_TIMESTAMPS"; payload: Partial<EditorState["timestamps"]> }
| { type: "RESET" }; | { type: "RESET" };
...@@ -63,7 +61,6 @@ export const initialState: EditorState = { ...@@ -63,7 +61,6 @@ export const initialState: EditorState = {
uploading: false, uploading: false,
loading: false, loading: false,
}, },
isDragging: false,
isComposing: false, isComposing: false,
}, },
timestamps: { timestamps: {
......
...@@ -18,7 +18,6 @@ export interface MemoEditorProps { ...@@ -18,7 +18,6 @@ export interface MemoEditorProps {
export interface EditorContentProps { export interface EditorContentProps {
placeholder?: string; placeholder?: string;
autoFocus?: boolean;
} }
export interface EditorToolbarProps { export interface EditorToolbarProps {
...@@ -57,7 +56,6 @@ export interface LocationDialogProps { ...@@ -57,7 +56,6 @@ export interface LocationDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
state: LocationState; state: LocationState;
locationInitialized: boolean;
onPositionChange: (position: LatLng) => void; onPositionChange: (position: LatLng) => void;
onUpdateCoordinate: (type: "lat" | "lng", value: string) => void; onUpdateCoordinate: (type: "lat" | "lng", value: string) => void;
onPlaceholderChange: (placeholder: string) => void; onPlaceholderChange: (placeholder: string) => void;
......
import { createContext } from "react";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import type { LocalFile } from "./attachment";
export interface MemoEditorContextValue {
attachmentList: Attachment[];
relationList: MemoRelation[];
setAttachmentList: (attachmentList: Attachment[]) => void;
setRelationList: (relationList: MemoRelation[]) => void;
memoName?: string;
addLocalFiles?: (files: LocalFile[]) => void;
removeLocalFile?: (previewUrl: string) => void;
localFiles?: LocalFile[];
}
const defaultContextValue: MemoEditorContextValue = {
attachmentList: [],
relationList: [],
setAttachmentList: () => {},
setRelationList: () => {},
addLocalFiles: () => {},
removeLocalFile: () => {},
localFiles: [],
};
export const MemoEditorContext = createContext<MemoEditorContextValue>(defaultContextValue);
...@@ -15,5 +15,4 @@ export type { ...@@ -15,5 +15,4 @@ export type {
TagSuggestionsProps, TagSuggestionsProps,
VisibilitySelectorProps, VisibilitySelectorProps,
} from "./components"; } from "./components";
export { MemoEditorContext, type MemoEditorContextValue } from "./context";
export type { LocationState } from "./insert-menu"; export type { LocationState } from "./insert-menu";
...@@ -23,6 +23,9 @@ const STUB_CONTEXT: MemoViewContextValue = { ...@@ -23,6 +23,9 @@ const STUB_CONTEXT: MemoViewContextValue = {
readonly: true, readonly: true,
showNSFWContent: false, showNSFWContent: false,
nsfw: false, nsfw: false,
openEditor: () => {},
toggleNsfwVisibility: () => {},
openPreview: () => {},
}; };
const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => { const AttachmentThumbnails = ({ attachments }: { attachments: Attachment[] }) => {
......
import { memo, useMemo, useRef, useState } from "react"; import { memo, useCallback, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { useUser } from "@/hooks/useUserQueries"; import { useUser } from "@/hooks/useUserQueries";
...@@ -9,7 +9,7 @@ import MemoEditor from "../MemoEditor"; ...@@ -9,7 +9,7 @@ import MemoEditor from "../MemoEditor";
import PreviewImageDialog from "../PreviewImageDialog"; import PreviewImageDialog from "../PreviewImageDialog";
import { MemoBody, MemoCommentListView, MemoHeader } from "./components"; import { MemoBody, MemoCommentListView, MemoHeader } from "./components";
import { MEMO_CARD_BASE_CLASSES } from "./constants"; import { MEMO_CARD_BASE_CLASSES } from "./constants";
import { useImagePreview, useMemoActions, useMemoHandlers } from "./hooks"; import { useImagePreview } from "./hooks";
import { computeCommentAmount, MemoViewContext } from "./MemoViewContext"; import { computeCommentAmount, MemoViewContext } from "./MemoViewContext";
import type { MemoViewProps } from "./types"; import type { MemoViewProps } from "./types";
...@@ -27,21 +27,12 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => { ...@@ -27,21 +27,12 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
// NSFW content management: always blur content tagged with NSFW (case-insensitive) // NSFW content management: always blur content tagged with NSFW (case-insensitive)
const [showNSFWContent, setShowNSFWContent] = useState(false); const [showNSFWContent, setShowNSFWContent] = useState(false);
const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false; const nsfw = memoData.tags?.some((tag) => tag.toUpperCase() === "NSFW") ?? false;
const toggleNsfwVisibility = () => setShowNSFWContent((prev) => !prev); const toggleNsfwVisibility = useCallback(() => setShowNSFWContent((prev) => !prev), []);
const { previewState, openPreview, setPreviewOpen } = useImagePreview(); const { previewState, openPreview, setPreviewOpen } = useImagePreview();
const { unpinMemo } = useMemoActions(memoData);
const closeEditor = () => setShowEditor(false); const openEditor = useCallback(() => setShowEditor(true), []);
const openEditor = () => setShowEditor(true); const closeEditor = useCallback(() => setShowEditor(false), []);
const { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({
memoName: memoData.name,
parentPage,
readonly,
openEditor,
openPreview,
});
const location = useLocation(); const location = useLocation();
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`); const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`);
...@@ -57,8 +48,23 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => { ...@@ -57,8 +48,23 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
readonly, readonly,
showNSFWContent, showNSFWContent,
nsfw, nsfw,
openEditor,
toggleNsfwVisibility,
openPreview,
}), }),
[memoData, creator, currentUser, parentPage, isArchived, readonly, showNSFWContent, nsfw], [
memoData,
creator,
currentUser,
parentPage,
isArchived,
readonly,
showNSFWContent,
nsfw,
openEditor,
toggleNsfwVisibility,
openPreview,
],
); );
if (showEditor) { if (showEditor) {
...@@ -80,21 +86,9 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => { ...@@ -80,21 +86,9 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
ref={cardRef} ref={cardRef}
tabIndex={readonly ? -1 : 0} tabIndex={readonly ? -1 : 0}
> >
<MemoHeader <MemoHeader showCreator={showCreator} showVisibility={showVisibility} showPinned={showPinned} />
showCreator={showCreator}
showVisibility={showVisibility}
showPinned={showPinned}
onEdit={openEditor}
onGotoDetail={handleGotoMemoDetailPage}
onUnpin={unpinMemo}
/>
<MemoBody <MemoBody compact={compact} />
compact={compact}
onContentClick={handleMemoContentClick}
onContentDoubleClick={handleMemoContentDoubleClick}
onToggleNsfwVisibility={toggleNsfwVisibility}
/>
<PreviewImageDialog <PreviewImageDialog
open={previewState.open} open={previewState.open}
......
...@@ -15,6 +15,9 @@ export interface MemoViewContextValue { ...@@ -15,6 +15,9 @@ export interface MemoViewContextValue {
readonly: boolean; readonly: boolean;
showNSFWContent: boolean; showNSFWContent: boolean;
nsfw: boolean; nsfw: boolean;
openEditor: () => void;
toggleNsfwVisibility: () => void;
openPreview: (urls: string | string[], index?: number) => void;
} }
export const MemoViewContext = createContext<MemoViewContextValue | null>(null); export const MemoViewContext = createContext<MemoViewContextValue | null>(null);
......
...@@ -3,6 +3,7 @@ import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; ...@@ -3,6 +3,7 @@ import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import MemoContent from "../../MemoContent"; import MemoContent from "../../MemoContent";
import { MemoReactionListView } from "../../MemoReactionListView"; import { MemoReactionListView } from "../../MemoReactionListView";
import { useMemoHandlers } from "../hooks";
import { useMemoViewContext } from "../MemoViewContext"; import { useMemoViewContext } from "../MemoViewContext";
import type { MemoBodyProps } from "../types"; import type { MemoBodyProps } from "../types";
import { AttachmentList, LocationDisplay, RelationList } from "./metadata"; import { AttachmentList, LocationDisplay, RelationList } from "./metadata";
...@@ -21,8 +22,10 @@ const NsfwOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => { ...@@ -21,8 +22,10 @@ const NsfwOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => {
); );
}; };
const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentDoubleClick, onToggleNsfwVisibility }) => { const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
const { memo, parentPage, showNSFWContent, nsfw } = useMemoViewContext(); const { memo, parentPage, showNSFWContent, nsfw, readonly, openEditor, openPreview, toggleNsfwVisibility } = useMemoViewContext();
const { handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ readonly, openEditor, openPreview });
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
...@@ -37,8 +40,8 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentD ...@@ -37,8 +40,8 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentD
<MemoContent <MemoContent
key={`${memo.name}-${memo.updateTime}`} key={`${memo.name}-${memo.updateTime}`}
content={memo.content} content={memo.content}
onClick={onContentClick} onClick={handleMemoContentClick}
onDoubleClick={onContentDoubleClick} onDoubleClick={handleMemoContentDoubleClick}
compact={memo.pinned ? false : compact} // Always show full content when pinned compact={memo.pinned ? false : compact} // Always show full content when pinned
/> />
<AttachmentList attachments={memo.attachments} /> <AttachmentList attachments={memo.attachments} />
...@@ -47,7 +50,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentD ...@@ -47,7 +50,7 @@ const MemoBody: React.FC<MemoBodyProps> = ({ compact, onContentClick, onContentD
<MemoReactionListView memo={memo} reactions={memo.reactions} /> <MemoReactionListView memo={memo} reactions={memo.reactions} />
</div> </div>
{nsfw && !showNSFWContent && <NsfwOverlay onClick={onToggleNsfwVisibility} />} {nsfw && !showNSFWContent && <NsfwOverlay onClick={toggleNsfwVisibility} />}
</> </>
); );
}; };
......
import { timestampDate } from "@bufbuild/protobuf/wkt"; import { timestampDate } from "@bufbuild/protobuf/wkt";
import { BookmarkIcon } from "lucide-react"; import { BookmarkIcon } from "lucide-react";
import { useState } from "react"; import { useCallback, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useNavigateTo from "@/hooks/useNavigateTo";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb"; import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
...@@ -13,16 +14,24 @@ import MemoActionMenu from "../../MemoActionMenu"; ...@@ -13,16 +14,24 @@ import MemoActionMenu from "../../MemoActionMenu";
import { ReactionSelector } from "../../MemoReactionListView"; import { ReactionSelector } from "../../MemoReactionListView";
import UserAvatar from "../../UserAvatar"; import UserAvatar from "../../UserAvatar";
import VisibilityIcon from "../../VisibilityIcon"; import VisibilityIcon from "../../VisibilityIcon";
import { useMemoActions } from "../hooks";
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext"; import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
import type { MemoHeaderProps } from "../types"; import type { MemoHeaderProps } from "../types";
const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, showPinned, onEdit, onGotoDetail, onUnpin }) => { const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, showPinned }) => {
const t = useTranslate(); const t = useTranslate();
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false); const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
const { memo, creator, currentUser, isArchived, readonly } = useMemoViewContext(); const { memo, creator, currentUser, parentPage, isArchived, readonly, openEditor } = useMemoViewContext();
const { relativeTimeFormat } = useMemoViewDerived(); const { relativeTimeFormat } = useMemoViewDerived();
const navigateTo = useNavigateTo();
const handleGotoMemoDetailPage = useCallback(() => {
navigateTo(`/${memo.name}`, { state: { from: parentPage } });
}, [memo.name, parentPage, navigateTo]);
const { unpinMemo } = useMemoActions(memo);
const displayTime = isArchived ? ( const displayTime = isArchived ? (
(memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language) (memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language)
) : ( ) : (
...@@ -37,9 +46,9 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh ...@@ -37,9 +46,9 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
<div className="w-full flex flex-row justify-between items-center gap-2"> <div className="w-full flex flex-row justify-between items-center gap-2">
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center"> <div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
{showCreator && creator ? ( {showCreator && creator ? (
<CreatorDisplay creator={creator} displayTime={displayTime} onGotoDetail={onGotoDetail} /> <CreatorDisplay creator={creator} displayTime={displayTime} onGotoDetail={handleGotoMemoDetailPage} />
) : ( ) : (
<TimeDisplay displayTime={displayTime} onGotoDetail={onGotoDetail} /> <TimeDisplay displayTime={displayTime} onGotoDetail={handleGotoMemoDetailPage} />
)} )}
</div> </div>
...@@ -70,7 +79,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh ...@@ -70,7 +79,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="cursor-pointer"> <span className="cursor-pointer">
<BookmarkIcon className="w-4 h-auto text-primary" onClick={onUnpin} /> <BookmarkIcon className="w-4 h-auto text-primary" onClick={unpinMemo} />
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
...@@ -80,7 +89,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh ...@@ -80,7 +89,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
</TooltipProvider> </TooltipProvider>
)} )}
<MemoActionMenu memo={memo} readonly={readonly} onEdit={onEdit} /> <MemoActionMenu memo={memo} readonly={readonly} onEdit={openEditor} />
</div> </div>
</div> </div>
); );
......
import { FileAudioIcon, FileIcon, PaperclipIcon } from "lucide-react"; import { FileAudioIcon, FileIcon, PaperclipIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import { formatFileSize, getFileTypeLabel } from "@/utils/format"; import { formatFileSize, getFileTypeLabel } from "@/utils/format";
import PreviewImageDialog from "../../../PreviewImageDialog"; import { useMemoViewContext } from "../../MemoViewContext";
import AttachmentCard from "./AttachmentCard"; import AttachmentCard from "./AttachmentCard";
import SectionHeader from "./SectionHeader"; import SectionHeader from "./SectionHeader";
...@@ -128,12 +128,7 @@ const DocsList = ({ attachments }: { attachments: Attachment[] }) => ( ...@@ -128,12 +128,7 @@ const DocsList = ({ attachments }: { attachments: Attachment[] }) => (
const Divider = () => <div className="border-t mt-1 border-border opacity-60" />; const Divider = () => <div className="border-t mt-1 border-border opacity-60" />;
const AttachmentList = ({ attachments }: AttachmentListProps) => { const AttachmentList = ({ attachments }: AttachmentListProps) => {
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number; mimeType?: string }>({ const { openPreview } = useMemoViewContext();
open: false,
urls: [],
index: 0,
mimeType: undefined,
});
const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]); const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]);
...@@ -146,38 +141,28 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => { ...@@ -146,38 +141,28 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
const handleImageClick = (imgUrl: string) => { const handleImageClick = (imgUrl: string) => {
const index = imageUrls.findIndex((url) => url === imgUrl); const index = imageUrls.findIndex((url) => url === imgUrl);
const mimeType = imageAttachments[index]?.type; openPreview(imageUrls, index >= 0 ? index : 0);
setPreviewImage({ open: true, urls: imageUrls, index, mimeType });
}; };
const sections = [visual.length > 0, audio.length > 0, docs.length > 0]; const sections = [visual.length > 0, audio.length > 0, docs.length > 0];
const sectionCount = sections.filter(Boolean).length; const sectionCount = sections.filter(Boolean).length;
return ( return (
<> <div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden"> <SectionHeader icon={PaperclipIcon} title="Attachments" count={attachments.length} />
<SectionHeader icon={PaperclipIcon} title="Attachments" count={attachments.length} />
<div className="p-1.5 flex flex-col gap-1"> <div className="p-1.5 flex flex-col gap-1">
{visual.length > 0 && <VisualGrid attachments={visual} onImageClick={handleImageClick} />} {visual.length > 0 && <VisualGrid attachments={visual} onImageClick={handleImageClick} />}
{visual.length > 0 && sectionCount > 1 && <Divider />} {visual.length > 0 && sectionCount > 1 && <Divider />}
{audio.length > 0 && <AudioList attachments={audio} />} {audio.length > 0 && <AudioList attachments={audio} />}
{audio.length > 0 && docs.length > 0 && <Divider />} {audio.length > 0 && docs.length > 0 && <Divider />}
{docs.length > 0 && <DocsList attachments={docs} />} {docs.length > 0 && <DocsList attachments={docs} />}
</div>
</div> </div>
</div>
<PreviewImageDialog
open={previewImage.open}
onOpenChange={(open: boolean) => setPreviewImage((prev) => ({ ...prev, open }))}
imgUrls={previewImage.urls}
initialIndex={previewImage.index}
/>
</>
); );
}; };
......
import { useState } from "react"; import { useCallback, useState } from "react";
export interface ImagePreviewState { export interface ImagePreviewState {
open: boolean; open: boolean;
...@@ -8,16 +8,20 @@ export interface ImagePreviewState { ...@@ -8,16 +8,20 @@ export interface ImagePreviewState {
export interface UseImagePreviewReturn { export interface UseImagePreviewReturn {
previewState: ImagePreviewState; previewState: ImagePreviewState;
openPreview: (url: string) => void; openPreview: (urls: string | string[], index?: number) => void;
setPreviewOpen: (open: boolean) => void; setPreviewOpen: (open: boolean) => void;
} }
export const useImagePreview = (): UseImagePreviewReturn => { export const useImagePreview = (): UseImagePreviewReturn => {
const [previewState, setPreviewState] = useState<ImagePreviewState>({ open: false, urls: [], index: 0 }); const [previewState, setPreviewState] = useState<ImagePreviewState>({ open: false, urls: [], index: 0 });
return { const openPreview = useCallback((urls: string | string[], index = 0) => {
previewState, setPreviewState({ open: true, urls: Array.isArray(urls) ? urls : [urls], index });
openPreview: (url: string) => setPreviewState({ open: true, urls: [url], index: 0 }), }, []);
setPreviewOpen: (open: boolean) => setPreviewState((prev) => ({ ...prev, open })),
}; const setPreviewOpen = useCallback((open: boolean) => {
setPreviewState((prev) => ({ ...prev, open }));
}, []);
return { previewState, openPreview, setPreviewOpen };
}; };
import { useCallback } from "react"; import { useCallback } from "react";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import useNavigateTo from "@/hooks/useNavigateTo";
interface UseMemoHandlersOptions { interface UseMemoHandlersOptions {
memoName: string;
parentPage: string;
readonly: boolean; readonly: boolean;
openEditor: () => void; openEditor: () => void;
openPreview: (url: string) => void; openPreview: (urls: string | string[], index?: number) => void;
} }
export const useMemoHandlers = (options: UseMemoHandlersOptions) => { export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
const { memoName, parentPage, readonly, openEditor, openPreview } = options; const { readonly, openEditor, openPreview } = options;
const navigateTo = useNavigateTo();
const { memoRelatedSetting } = useInstance(); const { memoRelatedSetting } = useInstance();
const handleGotoMemoDetailPage = useCallback(() => {
navigateTo(`/${memoName}`, { state: { from: parentPage } });
}, [memoName, parentPage, navigateTo]);
const handleMemoContentClick = useCallback( const handleMemoContentClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement; const targetEl = e.target as HTMLElement;
...@@ -43,5 +35,5 @@ export const useMemoHandlers = (options: UseMemoHandlersOptions) => { ...@@ -43,5 +35,5 @@ export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
[readonly, openEditor, memoRelatedSetting.enableDoubleClickEdit], [readonly, openEditor, memoRelatedSetting.enableDoubleClickEdit],
); );
return { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick }; return { handleMemoContentClick, handleMemoContentDoubleClick };
}; };
...@@ -14,14 +14,8 @@ export interface MemoHeaderProps { ...@@ -14,14 +14,8 @@ export interface MemoHeaderProps {
showCreator?: boolean; showCreator?: boolean;
showVisibility?: boolean; showVisibility?: boolean;
showPinned?: boolean; showPinned?: boolean;
onEdit: () => void;
onGotoDetail: () => void;
onUnpin: () => void;
} }
export interface MemoBodyProps { export interface MemoBodyProps {
compact?: boolean; compact?: boolean;
onContentClick: (e: React.MouseEvent) => void;
onContentDoubleClick: (e: React.MouseEvent) => void;
onToggleNsfwVisibility: () => void;
} }
...@@ -47,12 +47,7 @@ const MemoDetail = () => { ...@@ -47,12 +47,7 @@ const MemoDetail = () => {
return; return;
} }
if (error.code === Code.PermissionDenied) { if (error.code === Code.PermissionDenied || error.code === Code.NotFound) {
navigateTo("/403", { replace: true });
return;
}
if (error.code === Code.NotFound) {
navigateTo("/404", { replace: true }); navigateTo("/404", { replace: true });
return; return;
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment