Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
ea4e7a16
Unverified
Commit
ea4e7a16
authored
May 28, 2025
by
Johnny
Committed by
GitHub
May 28, 2025
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
refactor: memo editor (#4730)
parent
77d3853f
Changes
7
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
336 additions
and
314 deletions
+336
-314
MasonryView.tsx
web/src/components/MasonryView/MasonryView.tsx
+59
-85
VisibilitySelector.tsx
...components/MemoEditor/ActionButton/VisibilitySelector.tsx
+64
-0
index.tsx
web/src/components/MemoEditor/Editor/index.tsx
+10
-7
index.tsx
web/src/components/MemoEditor/index.tsx
+17
-33
MemoView.tsx
web/src/components/MemoView.tsx
+105
-111
PagedMemoList.tsx
web/src/components/PagedMemoList/PagedMemoList.tsx
+78
-76
VisibilityIcon.tsx
web/src/components/VisibilityIcon.tsx
+3
-2
No files found.
web/src/components/MasonryView/MasonryView.tsx
View file @
ea4e7a16
...
...
@@ -9,22 +9,15 @@ interface Props {
listMode
?:
boolean
;
}
interface
LocalState
{
columns
:
number
;
itemHeights
:
Map
<
string
,
number
>
;
columnHeights
:
number
[];
distribution
:
number
[][];
}
interface
MemoItemProps
{
memo
:
Memo
;
renderer
:
(
memo
:
Memo
)
=>
JSX
.
Element
;
onHeightChange
:
(
memoName
:
string
,
height
:
number
)
=>
void
;
}
// Minimum width required to show more than one column
const
MINIMUM_MEMO_VIEWPORT_WIDTH
=
512
;
// Component to wrap each memo and measure its height
const
MemoItem
=
({
memo
,
renderer
,
onHeightChange
}:
MemoItemProps
)
=>
{
const
itemRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
resizeObserverRef
=
useRef
<
ResizeObserver
|
null
>
(
null
);
...
...
@@ -39,41 +32,40 @@ const MemoItem = ({ memo, renderer, onHeightChange }: MemoItemProps) => {
}
};
// Initial measurement
measureHeight
();
// Set up ResizeObserver for dynamic content changes
resizeObserverRef
.
current
=
new
ResizeObserver
(()
=>
{
measureHeight
();
});
// Set up ResizeObserver to track dynamic content changes (images, expanded text, etc.)
resizeObserverRef
.
current
=
new
ResizeObserver
(
measureHeight
);
resizeObserverRef
.
current
.
observe
(
itemRef
.
current
);
return
()
=>
{
if
(
resizeObserverRef
.
current
)
{
resizeObserverRef
.
current
.
disconnect
();
}
resizeObserverRef
.
current
?.
disconnect
();
};
},
[
memo
.
name
,
onHeightChange
]);
return
<
div
ref=
{
itemRef
}
>
{
renderer
(
memo
)
}
</
div
>;
};
// Algorithm to distribute memos into columns based on height
/**
* Algorithm to distribute memos into columns based on height for balanced layout
* Uses greedy approach: always place next memo in the shortest column
*/
const
distributeMemosToColumns
=
(
memos
:
Memo
[],
columns
:
number
,
itemHeights
:
Map
<
string
,
number
>
,
prefixElementHeight
:
number
=
0
,
):
{
distribution
:
number
[][];
columnHeights
:
number
[]
}
=>
{
// List mode: all memos in single column
if
(
columns
===
1
)
{
// List mode - all memos in single column
const
totalHeight
=
memos
.
reduce
((
sum
,
memo
)
=>
sum
+
(
itemHeights
.
get
(
memo
.
name
)
||
0
),
prefixElementHeight
);
return
{
distribution
:
[
Array
.
from
(
Array
(
memos
.
length
).
keys
()
)],
columnHeights
:
[
memos
.
reduce
((
sum
,
memo
)
=>
sum
+
(
itemHeights
.
get
(
memo
.
name
)
||
0
),
prefixElementHeight
)
],
distribution
:
[
Array
.
from
(
{
length
:
memos
.
length
},
(
_
,
i
)
=>
i
)],
columnHeights
:
[
totalHeight
],
};
}
// Initialize columns and heights
const
distribution
:
number
[][]
=
Array
.
from
({
length
:
columns
},
()
=>
[]);
const
columnHeights
:
number
[]
=
Array
(
columns
).
fill
(
0
);
...
...
@@ -82,15 +74,12 @@ const distributeMemosToColumns = (
columnHeights
[
0
]
=
prefixElementHeight
;
}
// Distribute
memos to the shortest column each time
// Distribute
each memo to the shortest column
memos
.
forEach
((
memo
,
index
)
=>
{
const
height
=
itemHeights
.
get
(
memo
.
name
)
||
0
;
// Find the shortest column
const
shortestColumnIndex
=
columnHeights
.
reduce
(
(
minIndex
,
currentHeight
,
currentIndex
)
=>
(
currentHeight
<
columnHeights
[
minIndex
]
?
currentIndex
:
minIndex
),
0
,
);
// Find column with minimum height
const
shortestColumnIndex
=
columnHeights
.
indexOf
(
Math
.
min
(...
columnHeights
));
distribution
[
shortestColumnIndex
].
push
(
index
);
columnHeights
[
shortestColumnIndex
]
+=
height
;
...
...
@@ -100,97 +89,82 @@ const distributeMemosToColumns = (
};
const
MasonryView
=
(
props
:
Props
)
=>
{
const
[
state
,
setState
]
=
useState
<
LocalState
>
({
columns
:
1
,
itemHeights
:
new
Map
(),
columnHeights
:
[
0
],
distribution
:
[[]],
});
const
[
columns
,
setColumns
]
=
useState
(
1
);
const
[
itemHeights
,
setItemHeights
]
=
useState
<
Map
<
string
,
number
>>
(
new
Map
());
const
[
distribution
,
setDistribution
]
=
useState
<
number
[][]
>
([[]]);
const
containerRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
prefixElementRef
=
useRef
<
HTMLDivElement
>
(
null
);
// Calculate optimal number of columns based on container width
const
calculateColumns
=
useCallback
(()
=>
{
if
(
!
containerRef
.
current
||
props
.
listMode
)
return
1
;
const
containerWidth
=
containerRef
.
current
.
offsetWidth
;
const
scale
=
containerWidth
/
MINIMUM_MEMO_VIEWPORT_WIDTH
;
return
scale
>=
2
?
Math
.
round
(
scale
)
:
1
;
},
[
props
.
listMode
]);
// Recalculate memo distribution when layout changes
const
redistributeMemos
=
useCallback
(()
=>
{
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
const
{
distribution
:
newDistribution
}
=
distributeMemosToColumns
(
props
.
memoList
,
columns
,
itemHeights
,
prefixHeight
);
setDistribution
(
newDistribution
);
},
[
props
.
memoList
,
columns
,
itemHeights
]);
// Handle height changes from individual memo items
const
handleHeightChange
=
useCallback
(
(
memoName
:
string
,
height
:
number
)
=>
{
set
State
((
prevState
)
=>
{
const
newItemHeights
=
new
Map
(
prev
State
.
item
Heights
);
set
ItemHeights
((
prevHeights
)
=>
{
const
newItemHeights
=
new
Map
(
prevHeights
);
newItemHeights
.
set
(
memoName
,
height
);
// Recalculate distribution with new heights
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
const
{
distribution
,
columnHeights
}
=
distributeMemosToColumns
(
props
.
memoList
,
prevState
.
columns
,
newItemHeights
,
prefixHeight
);
return
{
...
prevState
,
itemHeights
:
newItemHeights
,
distribution
,
columnHeights
,
};
const
{
distribution
:
newDistribution
}
=
distributeMemosToColumns
(
props
.
memoList
,
columns
,
newItemHeights
,
prefixHeight
);
setDistribution
(
newDistribution
);
return
newItemHeights
;
});
},
[
props
.
memoList
],
[
props
.
memoList
,
columns
],
);
// Handle window resize and c
olumn count changes
// Handle window resize and c
alculate new column count
useEffect
(()
=>
{
const
handleResize
=
()
=>
{
if
(
!
containerRef
.
current
)
{
return
;
}
const
newColumns
=
props
.
listMode
?
1
:
(()
=>
{
const
containerWidth
=
containerRef
.
current
!
.
offsetWidth
;
const
scale
=
containerWidth
/
MINIMUM_MEMO_VIEWPORT_WIDTH
;
return
scale
>=
2
?
Math
.
round
(
scale
)
:
1
;
})();
if
(
!
containerRef
.
current
)
return
;
if
(
newColumns
!==
state
.
columns
)
{
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
const
{
distribution
,
columnHeights
}
=
distributeMemosToColumns
(
props
.
memoList
,
newColumns
,
state
.
itemHeights
,
prefixHeight
);
setState
((
prevState
)
=>
({
...
prevState
,
columns
:
newColumns
,
distribution
,
columnHeights
,
}));
const
newColumns
=
calculateColumns
();
if
(
newColumns
!==
columns
)
{
setColumns
(
newColumns
);
}
};
handleResize
();
window
.
addEventListener
(
"resize"
,
handleResize
);
return
()
=>
window
.
removeEventListener
(
"resize"
,
handleResize
);
},
[
props
.
listMode
,
state
.
columns
,
state
.
itemHeights
,
props
.
memoList
]);
},
[
calculateColumns
,
columns
]);
// Redistribute
when memo list changes
// Redistribute
memos when columns, memo list, or heights change
useEffect
(()
=>
{
const
prefixHeight
=
prefixElementRef
.
current
?.
offsetHeight
||
0
;
const
{
distribution
,
columnHeights
}
=
distributeMemosToColumns
(
props
.
memoList
,
state
.
columns
,
state
.
itemHeights
,
prefixHeight
);
setState
((
prevState
)
=>
({
...
prevState
,
distribution
,
columnHeights
,
}));
},
[
props
.
memoList
,
state
.
columns
,
state
.
itemHeights
]);
redistributeMemos
();
},
[
redistributeMemos
]);
return
(
<
div
ref=
{
containerRef
}
className=
{
cn
(
"w-full grid gap-2"
)
}
style=
{
{
gridTemplateColumns
:
`repeat(${
state.
columns}, 1fr)`
,
gridTemplateColumns
:
`repeat(${columns}, 1fr)`
,
}
}
>
{
Array
.
from
({
length
:
state
.
columns
}).
map
((
_
,
columnIndex
)
=>
(
{
Array
.
from
({
length
:
columns
}).
map
((
_
,
columnIndex
)
=>
(
<
div
key=
{
columnIndex
}
className=
"min-w-0 mx-auto w-full max-w-2xl"
>
{
props
.
prefixElement
&&
columnIndex
===
0
&&
(
<
div
ref=
{
prefixElementRef
}
className=
"mb-2"
>
{
props
.
prefixElement
}
</
div
>
)
}
{
state
.
distribution
[
columnIndex
]?.
map
((
memoIndex
)
=>
{
{
/* Prefix element (like memo editor) goes in first column */
}
{
props
.
prefixElement
&&
columnIndex
===
0
&&
<
div
ref=
{
prefixElementRef
}
>
{
props
.
prefixElement
}
</
div
>
}
{
distribution
[
columnIndex
]?.
map
((
memoIndex
)
=>
{
const
memo
=
props
.
memoList
[
memoIndex
];
return
memo
?
(
<
MemoItem
...
...
web/src/components/MemoEditor/ActionButton/VisibilitySelector.tsx
0 → 100644
View file @
ea4e7a16
import
{
ChevronDownIcon
}
from
"lucide-react"
;
import
{
useState
}
from
"react"
;
import
VisibilityIcon
from
"@/components/VisibilityIcon"
;
import
{
Popover
,
PopoverContent
,
PopoverTrigger
}
from
"@/components/ui/Popover"
;
import
{
Visibility
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
interface
Props
{
value
:
Visibility
;
onChange
:
(
visibility
:
Visibility
)
=>
void
;
className
?:
string
;
}
const
VisibilitySelector
=
(
props
:
Props
)
=>
{
const
{
value
,
onChange
,
className
}
=
props
;
const
t
=
useTranslate
();
const
[
open
,
setOpen
]
=
useState
(
false
);
const
visibilityOptions
=
[
{
value
:
Visibility
.
PRIVATE
,
label
:
t
(
"memo.visibility.private"
)
},
{
value
:
Visibility
.
PROTECTED
,
label
:
t
(
"memo.visibility.protected"
)
},
{
value
:
Visibility
.
PUBLIC
,
label
:
t
(
"memo.visibility.public"
)
},
];
const
currentOption
=
visibilityOptions
.
find
((
option
)
=>
option
.
value
===
value
);
const
handleSelect
=
(
visibility
:
Visibility
)
=>
{
onChange
(
visibility
);
setOpen
(
false
);
};
return
(
<
Popover
open=
{
open
}
onOpenChange=
{
setOpen
}
>
<
PopoverTrigger
asChild
>
<
button
className=
{
`flex items-center justify-center gap-1 px-0.5 text-xs rounded hover:bg-gray-100 dark:hover:bg-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 transition-colors ${className || ""}`
}
type=
"button"
>
<
VisibilityIcon
className=
"w-3 h-3"
visibility=
{
value
}
/>
<
span
className=
"hidden sm:inline"
>
{
currentOption
?.
label
}
</
span
>
<
ChevronDownIcon
className=
"w-3 h-3 opacity-60"
/>
</
button
>
</
PopoverTrigger
>
<
PopoverContent
className=
"!p-1"
align=
"end"
sideOffset=
{
2
}
alignOffset=
{
-
4
}
>
<
div
className=
"flex flex-col gap-0.5"
>
{
visibilityOptions
.
map
((
option
)
=>
(
<
button
key=
{
option
.
value
}
onClick=
{
()
=>
handleSelect
(
option
.
value
)
}
className=
{
`flex items-center gap-1 px-1 py-1 text-xs text-left dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 rounded transition-colors ${
option.value === value ? "bg-gray-50 dark:bg-zinc-800" : ""
}`
}
>
<
VisibilityIcon
className=
"w-3 h-3"
visibility=
{
option
.
value
}
/>
<
span
>
{
option
.
label
}
</
span
>
</
button
>
))
}
</
div
>
</
PopoverContent
>
</
Popover
>
);
};
export
default
VisibilitySelector
;
web/src/components/MemoEditor/Editor/index.tsx
View file @
ea4e7a16
...
...
@@ -169,11 +169,6 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
if
(
event
.
shiftKey
||
event
.
ctrlKey
||
event
.
metaKey
||
event
.
altKey
)
{
return
;
}
// Prevent a newline from being inserted, so that we can insert it manually later.
// This prevents a race condition that occurs between the newline insertion and
// inserting the insertText.
// Needs to be called before any async call.
event
.
preventDefault
();
const
cursorPosition
=
editorActions
.
getCursorPosition
();
const
prevContent
=
editorActions
.
getContent
().
substring
(
0
,
cursorPosition
);
...
...
@@ -210,7 +205,15 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
insertText
+=
" |"
;
}
editorActions
.
insertText
(
"
\n
"
+
insertText
);
if
(
insertText
)
{
// Prevent a newline from being inserted, so that we can insert it manually later.
// This prevents a race condition that occurs between the newline insertion and
// inserting the insertText.
// Needs to be called before any async call.
event
.
preventDefault
();
// Insert the text at the current cursor position
editorActions
.
insertText
(
"
\n
"
+
insertText
);
}
}
};
...
...
@@ -220,7 +223,7 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
>
<
textarea
className=
"w-full h-full my-1 text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none whitespace-pre-wrap word-break"
rows=
{
1
}
rows=
{
2
}
placeholder=
{
placeholder
}
ref=
{
editorRef
}
onPaste=
{
onPaste
}
...
...
web/src/components/MemoEditor/index.tsx
View file @
ea4e7a16
import
{
Select
,
Option
,
Divider
}
from
"@mui/joy"
;
import
{
Button
}
from
"@usememos/mui"
;
import
{
isEqual
}
from
"lodash-es"
;
import
{
LoaderIcon
,
SendIcon
}
from
"lucide-react"
;
...
...
@@ -17,14 +16,15 @@ import { memoStore, resourceStore, userStore, workspaceStore } from "@/store/v2"
import
{
Location
,
Memo
,
MemoRelation
,
MemoRelation_Type
,
Visibility
}
from
"@/types/proto/api/v1/memo_service"
;
import
{
Resource
}
from
"@/types/proto/api/v1/resource_service"
;
import
{
UserSetting
}
from
"@/types/proto/api/v1/user_service"
;
import
{
cn
}
from
"@/utils"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
convertVisibilityFromString
,
convertVisibilityToString
}
from
"@/utils/memo"
;
import
VisibilityIcon
from
"../VisibilityIcon"
;
import
{
convertVisibilityFromString
}
from
"@/utils/memo"
;
import
AddMemoRelationPopover
from
"./ActionButton/AddMemoRelationPopover"
;
import
LocationSelector
from
"./ActionButton/LocationSelector"
;
import
MarkdownMenu
from
"./ActionButton/MarkdownMenu"
;
import
TagSelector
from
"./ActionButton/TagSelector"
;
import
UploadResourceButton
from
"./ActionButton/UploadResourceButton"
;
import
VisibilitySelector
from
"./ActionButton/VisibilitySelector"
;
import
Editor
,
{
EditorRefActions
}
from
"./Editor"
;
import
RelationListView
from
"./RelationListView"
;
import
ResourceListView
from
"./ResourceListView"
;
...
...
@@ -468,13 +468,13 @@ const MemoEditor = observer((props: Props) => {
}
}
>
<
div
className=
{
`${
className ?? ""
} relative w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-800 px-4 pt-4 rounded-lg border ${
className=
{
cn
(
"group relative w-full flex flex-col justify-start items-start bg-white dark:bg-zinc-800 px-4 pt-3 pb-2 rounded-lg border"
,
state
.
isDraggingFile
?
"border-dashed border-gray-400 dark:border-primary-400 cursor-copy"
: "border-gray-200 dark:border-zinc-700 cursor-auto"
}`
}
:
"border-gray-200 dark:border-zinc-700 cursor-auto"
,
className
,
)
}
tabIndex=
{
0
}
onKeyDown=
{
handleKeyDown
}
onDrop=
{
handleDropEvent
}
...
...
@@ -500,7 +500,7 @@ const MemoEditor = observer((props: Props) => {
<
Editor
ref=
{
editorRef
}
{
...
editorConfig
}
/>
<
ResourceListView
resourceList=
{
state
.
resourceList
}
setResourceList=
{
handleSetResourceList
}
/>
<
RelationListView
relationList=
{
referenceRelations
}
setRelationList=
{
handleSetRelationList
}
/>
<
div
className=
"relative w-full flex flex-row justify-between items-center p
t-2
"
onFocus=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
div
className=
"relative w-full flex flex-row justify-between items-center p
y-1
"
onFocus=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
div
className=
"flex flex-row justify-start items-center opacity-80 dark:opacity-60 space-x-2"
>
<
TagSelector
editorRef=
{
editorRef
}
/>
<
MarkdownMenu
editorRef=
{
editorRef
}
/>
...
...
@@ -516,31 +516,9 @@ const MemoEditor = observer((props: Props) => {
}
/>
</
div
>
</
div
>
<
Divider
className=
"!mt-2 opacity-40"
/>
<
div
className=
"w-full flex flex-row justify-between items-center py-3 gap-2 overflow-auto dark:border-t-zinc-500"
>
<
div
className=
"relative flex flex-row justify-start items-center"
onFocus=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
Select
variant=
"plain"
size=
"sm"
value=
{
state
.
memoVisibility
}
startDecorator=
{
<
VisibilityIcon
visibility=
{
state
.
memoVisibility
}
/>
}
onChange=
{
(
_
,
visibility
)
=>
{
if
(
visibility
)
{
handleMemoVisibilityChange
(
visibility
);
}
}
}
>
{
[
Visibility
.
PRIVATE
,
Visibility
.
PROTECTED
,
Visibility
.
PUBLIC
].
map
((
item
)
=>
(
<
Option
key=
{
item
}
value=
{
item
}
className=
"whitespace-nowrap !text-sm"
>
{
t
(
`memo.visibility.${convertVisibilityToString(item).toLowerCase()}`
as
any
)
}
</
Option
>
))
}
</
Select
>
</
div
>
<
div
className=
"shrink-0 flex flex-row justify-end items-center gap-2"
>
<
div
className=
"shrink-0 -mr-1 flex flex-row justify-end items-center"
>
{
props
.
onCancel
&&
(
<
Button
variant=
"plain"
disabled=
{
state
.
isRequesting
}
onClick=
{
handleCancelBtnClick
}
>
<
Button
variant=
"plain"
className=
"opacity-60"
disabled=
{
state
.
isRequesting
}
onClick=
{
handleCancelBtnClick
}
>
{
t
(
"common.cancel"
)
}
</
Button
>
)
}
...
...
@@ -550,6 +528,12 @@ const MemoEditor = observer((props: Props) => {
</
Button
>
</
div
>
</
div
>
<
div
className=
"absolute invisible group-focus-within:visible group-hover:visible right-1 top-1 opacity-60"
onFocus=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
VisibilitySelector
value=
{
state
.
memoVisibility
}
onChange=
{
handleMemoVisibilityChange
}
/>
</
div
>
</
div
>
</
MemoEditorContext
.
Provider
>
);
...
...
web/src/components/MemoView.tsx
View file @
ea4e7a16
import
{
Tooltip
}
from
"@mui/joy"
;
import
{
BookmarkIcon
,
EyeOffIcon
,
MessageCircleMoreIcon
}
from
"lucide-react"
;
import
{
observer
}
from
"mobx-react-lite"
;
import
{
memo
,
useCallback
,
use
Ref
,
use
State
}
from
"react"
;
import
{
memo
,
useCallback
,
useState
}
from
"react"
;
import
{
Link
,
useLocation
}
from
"react-router-dom"
;
import
useAsyncEffect
from
"@/hooks/useAsyncEffect"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
...
...
@@ -47,7 +47,6 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
const
[
showEditor
,
setShowEditor
]
=
useState
<
boolean
>
(
false
);
const
[
creator
,
setCreator
]
=
useState
(
userStore
.
getUserByName
(
memo
.
creator
));
const
[
showNSFWContent
,
setShowNSFWContent
]
=
useState
(
props
.
showNsfwContent
);
const
memoContainerRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
workspaceMemoRelatedSetting
=
workspaceStore
.
state
.
memoRelatedSetting
;
const
referencedMemos
=
memo
.
relations
.
filter
((
relation
)
=>
relation
.
type
===
MemoRelation_Type
.
REFERENCE
);
const
commentAmount
=
memo
.
relations
.
filter
(
...
...
@@ -121,131 +120,126 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
<
relative
-
time
datetime=
{
memo
.
displayTime
?.
toISOString
()
}
format=
{
relativeTimeFormat
}
></
relative
-
time
>
);
return
(
return
showEditor
?
(
<
MemoEditor
autoFocus
className=
"mb-2"
cacheKey=
{
`inline-memo-editor-${memo.name}`
}
memoName=
{
memo
.
name
}
onConfirm=
{
onEditorConfirm
}
onCancel=
{
()
=>
setShowEditor
(
false
)
}
/>
)
:
(
<
div
className=
{
cn
(
"group relative flex flex-col justify-start items-start w-full px-4 py-3 mb-2 gap-2 bg-white dark:bg-zinc-800 rounded-lg border border-white dark:border-zinc-800 hover:border-gray-200 dark:hover:border-zinc-700"
,
className
,
)
}
ref=
{
memoContainerRef
}
>
{
showEditor
?
(
<
MemoEditor
autoFocus
className=
"border-none !p-0 -mb-2"
cacheKey=
{
`inline-memo-editor-${memo.name}`
}
memoName=
{
memo
.
name
}
onConfirm=
{
onEditorConfirm
}
onCancel=
{
()
=>
setShowEditor
(
false
)
}
/>
)
:
(
<>
<
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"
>
{
props
.
showCreator
&&
creator
?
(
<
div
className=
"w-full flex flex-row justify-start items-center"
>
<
Link
className=
"w-auto hover:opacity-80"
to=
{
`/u/${encodeURIComponent(creator.username)}`
}
viewTransition
>
<
UserAvatar
className=
"mr-2 shrink-0"
avatarUrl=
{
creator
.
avatarUrl
}
/>
</
Link
>
<
div
className=
"w-full flex flex-col justify-center items-start"
>
<
Link
className=
"w-full block leading-tight hover:opacity-80 truncate text-gray-600 dark:text-gray-400"
to=
{
`/u/${encodeURIComponent(creator.username)}`
}
viewTransition
>
{
creator
.
nickname
||
creator
.
username
}
</
Link
>
<
div
className=
"w-auto -mt-0.5 text-xs leading-tight text-gray-400 dark:text-gray-500 select-none cursor-pointer"
onClick=
{
handleGotoMemoDetailPage
}
>
{
displayTime
}
</
div
>
</
div
>
</
div
>
)
:
(
<
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"
>
{
props
.
showCreator
&&
creator
?
(
<
div
className=
"w-full flex flex-row justify-start items-center"
>
<
Link
className=
"w-auto hover:opacity-80"
to=
{
`/u/${encodeURIComponent(creator.username)}`
}
viewTransition
>
<
UserAvatar
className=
"mr-2 shrink-0"
avatarUrl=
{
creator
.
avatarUrl
}
/>
</
Link
>
<
div
className=
"w-full flex flex-col justify-center items-start"
>
<
Link
className=
"w-full block leading-tight hover:opacity-80 truncate text-gray-600 dark:text-gray-400"
to=
{
`/u/${encodeURIComponent(creator.username)}`
}
viewTransition
>
{
creator
.
nickname
||
creator
.
username
}
</
Link
>
<
div
className=
"w-
full text-sm
leading-tight text-gray-400 dark:text-gray-500 select-none cursor-pointer"
className=
"w-
auto -mt-0.5 text-xs
leading-tight text-gray-400 dark:text-gray-500 select-none cursor-pointer"
onClick=
{
handleGotoMemoDetailPage
}
>
{
displayTime
}
</
div
>
)
}
</
div
>
<
div
className=
"flex flex-row justify-end items-center select-none shrink-0 gap-2"
>
<
div
className=
"w-auto invisible group-hover:visible flex flex-row justify-between items-center gap-2"
>
{
props
.
showVisibility
&&
memo
.
visibility
!==
Visibility
.
PRIVATE
&&
(
<
Tooltip
title=
{
t
(
`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}`
as
any
)
}
placement=
"top"
>
<
span
className=
"flex justify-center items-center hover:opacity-70"
>
<
VisibilityIcon
visibility=
{
memo
.
visibility
}
/>
</
span
>
</
Tooltip
>
)
}
{
currentUser
&&
!
isArchived
&&
<
ReactionSelector
className=
"border-none w-auto h-auto"
memo=
{
memo
}
/>
}
</
div
>
{
!
isInMemoDetailPage
&&
(
workspaceMemoRelatedSetting
.
enableComment
||
commentAmount
>
0
)
&&
(
<
Link
className=
{
cn
(
"flex flex-row justify-start items-center hover:opacity-70"
,
commentAmount
===
0
&&
"invisible group-hover:visible"
,
)
}
to=
{
`/${memo.name}#comments`
}
viewTransition
state=
{
{
from
:
parentPage
,
}
}
>
<
MessageCircleMoreIcon
className=
"w-4 h-4 mx-auto text-gray-500 dark:text-gray-400"
/>
{
commentAmount
>
0
&&
<
span
className=
"text-xs text-gray-500 dark:text-gray-400"
>
{
commentAmount
}
</
span
>
}
</
Link
>
)
}
{
props
.
showPinned
&&
memo
.
pinned
&&
(
<
Tooltip
title=
{
t
(
"common.unpin"
)
}
placement=
"top"
>
<
span
className=
"cursor-pointer"
>
<
BookmarkIcon
className=
"w-4 h-auto text-amber-500"
onClick=
{
onPinIconClick
}
/>
</
span
>
</
Tooltip
>
)
}
{
nsfw
&&
showNSFWContent
&&
(
<
span
className=
"cursor-pointer"
>
<
EyeOffIcon
className=
"w-4 h-auto text-amber-500"
onClick=
{
()
=>
setShowNSFWContent
(
false
)
}
/>
</
span
>
)
}
<
MemoActionMenu
className=
"-ml-1"
memo=
{
memo
}
readonly=
{
readonly
}
onEdit=
{
()
=>
setShowEditor
(
true
)
}
/>
</
div
>
</
div
>
<
div
className=
{
cn
(
"w-full flex flex-col justify-start items-start gap-2"
,
nsfw
&&
!
showNSFWContent
&&
"blur-lg transition-all duration-200"
,
)
:
(
<
div
className=
"w-full text-sm leading-tight text-gray-400 dark:text-gray-500 select-none cursor-pointer"
onClick=
{
handleGotoMemoDetailPage
}
>
{
displayTime
}
</
div
>
)
}
</
div
>
<
div
className=
"flex flex-row justify-end items-center select-none shrink-0 gap-2"
>
<
div
className=
"w-auto invisible group-hover:visible flex flex-row justify-between items-center gap-2"
>
{
props
.
showVisibility
&&
memo
.
visibility
!==
Visibility
.
PRIVATE
&&
(
<
Tooltip
title=
{
t
(
`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}`
as
any
)
}
placement=
"top"
>
<
span
className=
"flex justify-center items-center hover:opacity-70"
>
<
VisibilityIcon
visibility=
{
memo
.
visibility
}
/>
</
span
>
</
Tooltip
>
)
}
>
<
MemoContent
key=
{
`${memo.name}-${memo.updateTime}`
}
memoName=
{
memo
.
name
}
nodes=
{
memo
.
nodes
}
readonly=
{
readonly
}
onClick=
{
handleMemoContentClick
}
onDoubleClick=
{
handleMemoContentDoubleClick
}
compact=
{
memo
.
pinned
?
false
:
props
.
compact
}
// Always show full content when pinned.
parentPage=
{
parentPage
}
/>
{
memo
.
location
&&
<
MemoLocationView
location=
{
memo
.
location
}
/>
}
<
MemoResourceListView
resources=
{
memo
.
resources
}
/>
<
MemoRelationListView
memo=
{
memo
}
relations=
{
referencedMemos
}
parentPage=
{
parentPage
}
/>
<
MemoReactionistView
memo=
{
memo
}
reactions=
{
memo
.
reactions
}
/>
{
currentUser
&&
!
isArchived
&&
<
ReactionSelector
className=
"border-none w-auto h-auto"
memo=
{
memo
}
/>
}
</
div
>
{
nsfw
&&
!
showNSFWContent
&&
(
<>
<
div
className=
"absolute inset-0 bg-transparent"
/>
<
button
className=
"absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 py-2 px-4 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-zinc-800"
onClick=
{
()
=>
setShowNSFWContent
(
true
)
}
>
{
t
(
"memo.click-to-show-nsfw-content"
)
}
</
button
>
</>
{
!
isInMemoDetailPage
&&
(
workspaceMemoRelatedSetting
.
enableComment
||
commentAmount
>
0
)
&&
(
<
Link
className=
{
cn
(
"flex flex-row justify-start items-center hover:opacity-70"
,
commentAmount
===
0
&&
"invisible group-hover:visible"
,
)
}
to=
{
`/${memo.name}#comments`
}
viewTransition
state=
{
{
from
:
parentPage
,
}
}
>
<
MessageCircleMoreIcon
className=
"w-4 h-4 mx-auto text-gray-500 dark:text-gray-400"
/>
{
commentAmount
>
0
&&
<
span
className=
"text-xs text-gray-500 dark:text-gray-400"
>
{
commentAmount
}
</
span
>
}
</
Link
>
)
}
{
props
.
showPinned
&&
memo
.
pinned
&&
(
<
Tooltip
title=
{
t
(
"common.unpin"
)
}
placement=
"top"
>
<
span
className=
"cursor-pointer"
>
<
BookmarkIcon
className=
"w-4 h-auto text-amber-500"
onClick=
{
onPinIconClick
}
/>
</
span
>
</
Tooltip
>
)
}
{
nsfw
&&
showNSFWContent
&&
(
<
span
className=
"cursor-pointer"
>
<
EyeOffIcon
className=
"w-4 h-auto text-amber-500"
onClick=
{
()
=>
setShowNSFWContent
(
false
)
}
/>
</
span
>
)
}
<
MemoActionMenu
className=
"-ml-1"
memo=
{
memo
}
readonly=
{
readonly
}
onEdit=
{
()
=>
setShowEditor
(
true
)
}
/>
</
div
>
</
div
>
<
div
className=
{
cn
(
"w-full flex flex-col justify-start items-start gap-2"
,
nsfw
&&
!
showNSFWContent
&&
"blur-lg transition-all duration-200"
,
)
}
>
<
MemoContent
key=
{
`${memo.name}-${memo.updateTime}`
}
memoName=
{
memo
.
name
}
nodes=
{
memo
.
nodes
}
readonly=
{
readonly
}
onClick=
{
handleMemoContentClick
}
onDoubleClick=
{
handleMemoContentDoubleClick
}
compact=
{
memo
.
pinned
?
false
:
props
.
compact
}
// Always show full content when pinned.
parentPage=
{
parentPage
}
/>
{
memo
.
location
&&
<
MemoLocationView
location=
{
memo
.
location
}
/>
}
<
MemoResourceListView
resources=
{
memo
.
resources
}
/>
<
MemoRelationListView
memo=
{
memo
}
relations=
{
referencedMemos
}
parentPage=
{
parentPage
}
/>
<
MemoReactionistView
memo=
{
memo
}
reactions=
{
memo
.
reactions
}
/>
</
div
>
{
nsfw
&&
!
showNSFWContent
&&
(
<>
<
div
className=
"absolute inset-0 bg-transparent"
/>
<
button
className=
"absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 py-2 px-4 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-zinc-800"
onClick=
{
()
=>
setShowNSFWContent
(
true
)
}
>
{
t
(
"memo.click-to-show-nsfw-content"
)
}
</
button
>
</>
)
}
</
div
>
...
...
web/src/components/PagedMemoList/PagedMemoList.tsx
View file @
ea4e7a16
...
...
@@ -26,108 +26,115 @@ interface Props {
pageSize
?:
number
;
}
interface
LocalState
{
isRequesting
:
boolean
;
nextPageToken
:
string
;
}
const
PagedMemoList
=
observer
((
props
:
Props
)
=>
{
const
t
=
useTranslate
();
const
{
md
}
=
useResponsiveWidth
();
const
[
state
,
setState
]
=
useState
<
LocalState
>
({
isRequesting
:
true
,
// Initial request
nextPageToken
:
""
,
});
const
checkTimeoutRef
=
useRef
<
number
|
null
>
(
null
);
// Simplified state management - separate state variables for clarity
const
[
isRequesting
,
setIsRequesting
]
=
useState
(
true
);
const
[
nextPageToken
,
setNextPageToken
]
=
useState
(
""
);
// Ref to manage auto-fetch timeout to prevent memory leaks
const
autoFetchTimeoutRef
=
useRef
<
number
|
null
>
(
null
);
// Apply custom sorting if provided, otherwise use store memos directly
const
sortedMemoList
=
props
.
listSort
?
props
.
listSort
(
memoStore
.
state
.
memos
)
:
memoStore
.
state
.
memos
;
// Show memo editor only on the root route
const
showMemoEditor
=
Boolean
(
matchPath
(
Routes
.
ROOT
,
window
.
location
.
pathname
));
const
fetchMoreMemos
=
async
(
nextPageToken
:
string
)
=>
{
setState
((
state
)
=>
({
...
state
,
isRequesting
:
true
}));
const
response
=
await
memoStore
.
fetchMemos
({
parent
:
props
.
owner
||
""
,
state
:
props
.
state
||
State
.
NORMAL
,
direction
:
props
.
direction
||
Direction
.
DESC
,
filter
:
props
.
filter
||
""
,
oldFilter
:
props
.
oldFilter
||
""
,
pageSize
:
props
.
pageSize
||
DEFAULT_LIST_MEMOS_PAGE_SIZE
,
pageToken
:
nextPageToken
,
});
setState
(()
=>
({
isRequesting
:
false
,
nextPageToken
:
response
?.
nextPageToken
||
""
,
}));
// Fetch more memos with pagination support
const
fetchMoreMemos
=
async
(
pageToken
:
string
)
=>
{
setIsRequesting
(
true
);
try
{
const
response
=
await
memoStore
.
fetchMemos
({
parent
:
props
.
owner
||
""
,
state
:
props
.
state
||
State
.
NORMAL
,
direction
:
props
.
direction
||
Direction
.
DESC
,
filter
:
props
.
filter
||
""
,
oldFilter
:
props
.
oldFilter
||
""
,
pageSize
:
props
.
pageSize
||
DEFAULT_LIST_MEMOS_PAGE_SIZE
,
pageToken
,
});
setNextPageToken
(
response
?.
nextPageToken
||
""
);
}
finally
{
setIsRequesting
(
false
);
}
};
// Helper function to check if page has enough content to be scrollable
const
isPageScrollable
=
()
=>
{
const
documentHeight
=
Math
.
max
(
document
.
body
.
scrollHeight
,
document
.
documentElement
.
scrollHeight
);
return
documentHeight
>
window
.
innerHeight
+
100
;
// 100px buffer for safe measure
};
//
Check if content fills the viewport and fetch more if needed
//
Auto-fetch more content if page isn't scrollable and more data is available
const
checkAndFetchIfNeeded
=
useCallback
(
async
()
=>
{
// Clear any pending
checks
if
(
check
TimeoutRef
.
current
)
{
clearTimeout
(
check
TimeoutRef
.
current
);
// Clear any pending
auto-fetch timeout
if
(
autoFetch
TimeoutRef
.
current
)
{
clearTimeout
(
autoFetch
TimeoutRef
.
current
);
}
// Wait
a bit for DOM to update after memo list changes
// Wait
for DOM to update before checking scrollability
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
200
));
// Check if page is scrollable using multiple methods for better reliability
const
documentHeight
=
Math
.
max
(
document
.
body
.
scrollHeight
,
document
.
body
.
offsetHeight
,
document
.
documentElement
.
clientHeight
,
document
.
documentElement
.
scrollHeight
,
document
.
documentElement
.
offsetHeight
,
);
const
windowHeight
=
window
.
innerHeight
;
const
isScrollable
=
documentHeight
>
windowHeight
+
100
;
// 100px buffer
// If not scrollable and we have more data to fetch and not currently fetching
if
(
!
isScrollable
&&
state
.
nextPageToken
&&
!
state
.
isRequesting
&&
sortedMemoList
.
length
>
0
)
{
await
fetchMoreMemos
(
state
.
nextPageToken
);
// Schedule another check after a delay to prevent rapid successive calls
checkTimeoutRef
.
current
=
window
.
setTimeout
(()
=>
{
// Only fetch if: page isn't scrollable, we have more data, not currently loading, and have memos
const
shouldFetch
=
!
isPageScrollable
()
&&
nextPageToken
&&
!
isRequesting
&&
sortedMemoList
.
length
>
0
;
if
(
shouldFetch
)
{
await
fetchMoreMemos
(
nextPageToken
);
// Schedule another check with delay to prevent rapid successive calls
autoFetchTimeoutRef
.
current
=
window
.
setTimeout
(()
=>
{
checkAndFetchIfNeeded
();
},
500
);
}
},
[
state
.
nextPageToken
,
state
.
isRequesting
,
sortedMemoList
.
length
]);
},
[
nextPageToken
,
isRequesting
,
sortedMemoList
.
length
]);
// Refresh the entire memo list from the beginning
const
refreshList
=
async
()
=>
{
memoStore
.
state
.
updateStateId
();
set
State
((
state
)
=>
({
...
state
,
nextPageToken
:
""
})
);
set
NextPageToken
(
""
);
await
fetchMoreMemos
(
""
);
};
// Initial load and reload when props change
useEffect
(()
=>
{
refreshList
();
},
[
props
.
owner
,
props
.
state
,
props
.
direction
,
props
.
filter
,
props
.
oldFilter
,
props
.
pageSize
]);
//
Check if we need to fetch more data when content changes.
//
Auto-fetch more content when list changes and page isn't full
useEffect
(()
=>
{
if
(
!
state
.
isRequesting
&&
sortedMemoList
.
length
>
0
)
{
if
(
!
isRequesting
&&
sortedMemoList
.
length
>
0
)
{
checkAndFetchIfNeeded
();
}
},
[
sortedMemoList
.
length
,
state
.
isRequesting
,
state
.
nextPageToken
,
checkAndFetchIfNeeded
]);
},
[
sortedMemoList
.
length
,
isRequesting
,
nextPageToken
,
checkAndFetchIfNeeded
]);
// Cleanup timeout on
unmount.
// Cleanup timeout on
component unmount
useEffect
(()
=>
{
return
()
=>
{
if
(
check
TimeoutRef
.
current
)
{
clearTimeout
(
check
TimeoutRef
.
current
);
if
(
autoFetch
TimeoutRef
.
current
)
{
clearTimeout
(
autoFetch
TimeoutRef
.
current
);
}
};
},
[]);
// Infinite scroll: fetch more when user scrolls near bottom
useEffect
(()
=>
{
if
(
!
state
.
nextPageToken
)
return
;
if
(
!
nextPageToken
)
return
;
const
handleScroll
=
()
=>
{
const
nearBottom
=
window
.
innerHeight
+
window
.
scrollY
>=
document
.
body
.
offsetHeight
-
300
;
if
(
nearBottom
&&
!
state
.
isRequesting
)
{
fetchMoreMemos
(
state
.
nextPageToken
);
if
(
nearBottom
&&
!
isRequesting
)
{
fetchMoreMemos
(
nextPageToken
);
}
};
window
.
addEventListener
(
"scroll"
,
handleScroll
);
return
()
=>
window
.
removeEventListener
(
"scroll"
,
handleScroll
);
},
[
state
.
nextPageToken
,
state
.
isRequesting
]);
},
[
nextPageToken
,
isRequesting
]);
const
children
=
(
<
div
className=
"flex flex-col justify-start items-start w-full max-w-full"
>
...
...
@@ -137,14 +144,18 @@ const PagedMemoList = observer((props: Props) => {
prefixElement=
{
showMemoEditor
?
<
MemoEditor
className=
"mb-2"
cacheKey=
"home-memo-editor"
/>
:
undefined
}
listMode=
{
viewStore
.
state
.
layout
===
"LIST"
}
/>
{
state
.
isRequesting
&&
(
{
/* Loading indicator */
}
{
isRequesting
&&
(
<
div
className=
"w-full flex flex-row justify-center items-center my-4"
>
<
LoaderIcon
className=
"animate-spin text-zinc-500"
/>
</
div
>
)
}
{
!
state
.
isRequesting
&&
(
{
/* Empty state or back-to-top button */
}
{
!
isRequesting
&&
(
<>
{
!
state
.
nextPageToken
&&
sortedMemoList
.
length
===
0
?
(
{
!
nextPageToken
&&
sortedMemoList
.
length
===
0
?
(
<
div
className=
"w-full mt-12 mb-8 flex flex-col justify-center items-center italic"
>
<
Empty
/>
<
p
className=
"mt-2 text-gray-600 dark:text-gray-400"
>
{
t
(
"message.no-data"
)
}
</
p
>
...
...
@@ -159,7 +170,6 @@ const PagedMemoList = observer((props: Props) => {
</
div
>
);
// In case of md screen, we don't need pull to refresh.
if
(
md
)
{
return
children
;
}
...
...
@@ -186,25 +196,16 @@ const PagedMemoList = observer((props: Props) => {
const
BackToTop
=
()
=>
{
const
t
=
useTranslate
();
const
[
isVisible
,
setIsVisible
]
=
useState
(
false
);
const
[
shouldRender
,
setShouldRender
]
=
useState
(
false
);
useEffect
(()
=>
{
const
handleScroll
=
()
=>
{
const
shouldBeVisible
=
window
.
scrollY
>
400
;
if
(
shouldBeVisible
!==
isVisible
)
{
if
(
shouldBeVisible
)
{
setShouldRender
(
true
);
setIsVisible
(
true
);
}
else
{
setShouldRender
(
false
);
setIsVisible
(
false
);
}
}
const
shouldShow
=
window
.
scrollY
>
400
;
setIsVisible
(
shouldShow
);
};
window
.
addEventListener
(
"scroll"
,
handleScroll
);
return
()
=>
window
.
removeEventListener
(
"scroll"
,
handleScroll
);
},
[
isVisible
]);
},
[]);
const
scrollToTop
=
()
=>
{
window
.
scrollTo
({
...
...
@@ -213,7 +214,8 @@ const BackToTop = () => {
});
};
if
(
!
shouldRender
)
{
// Don't render if not visible
if
(
!
isVisible
)
{
return
null
;
}
...
...
web/src/components/VisibilityIcon.tsx
View file @
ea4e7a16
...
...
@@ -4,10 +4,11 @@ import { cn } from "@/utils";
interface
Props
{
visibility
:
Visibility
;
className
?:
string
;
}
const
VisibilityIcon
=
(
props
:
Props
)
=>
{
const
{
visibility
}
=
props
;
const
{
className
,
visibility
}
=
props
;
let
VIcon
=
null
;
if
(
visibility
===
Visibility
.
PRIVATE
)
{
...
...
@@ -21,7 +22,7 @@ const VisibilityIcon = (props: Props) => {
return
null
;
}
return
<
VIcon
className=
{
cn
(
"w-4 h-auto text-gray-500 dark:text-gray-400"
)
}
/>;
return
<
VIcon
className=
{
cn
(
"w-4 h-auto text-gray-500 dark:text-gray-400"
,
className
)
}
/>;
};
export
default
VisibilityIcon
;
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment