mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge branch 'main' into workspace-invite
This commit is contained in:
commit
65a6b394c4
@ -20,7 +20,6 @@ const List<FieldType> _supportedFieldTypes = [
|
|||||||
FieldType.URL,
|
FieldType.URL,
|
||||||
FieldType.LastEditedTime,
|
FieldType.LastEditedTime,
|
||||||
FieldType.CreatedTime,
|
FieldType.CreatedTime,
|
||||||
FieldType.Relation,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate {
|
class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate {
|
||||||
|
@ -67,13 +67,13 @@ class _ChecklistItemsState extends State<ChecklistItems> {
|
|||||||
(index, task) => Padding(
|
(index, task) => Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
padding: const EdgeInsets.symmetric(vertical: 2.0),
|
||||||
child: ChecklistItem(
|
child: ChecklistItem(
|
||||||
|
key: ValueKey(task.data.id),
|
||||||
task: task,
|
task: task,
|
||||||
autofocus: widget.state.newTask && index == tasks.length - 1,
|
autofocus: widget.state.newTask && index == tasks.length - 1,
|
||||||
onSubmitted: () {
|
onSubmitted: index == tasks.length - 1
|
||||||
if (index == tasks.length - 1) {
|
? () => widget.bloc
|
||||||
widget.bloc.add(const ChecklistCellEvent.createNewTask(""));
|
.add(const ChecklistCellEvent.createNewTask(""))
|
||||||
}
|
: null,
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -136,14 +136,6 @@ class _SelectTaskIntent extends Intent {
|
|||||||
const _SelectTaskIntent();
|
const _SelectTaskIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DeleteTaskIntent extends Intent {
|
|
||||||
const _DeleteTaskIntent();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StartEditingTaskIntent extends Intent {
|
|
||||||
const _StartEditingTaskIntent();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EndEditingTaskIntent extends Intent {
|
class _EndEditingTaskIntent extends Intent {
|
||||||
const _EndEditingTaskIntent();
|
const _EndEditingTaskIntent();
|
||||||
}
|
}
|
||||||
@ -168,7 +160,9 @@ class ChecklistItem extends StatefulWidget {
|
|||||||
|
|
||||||
class _ChecklistItemState extends State<ChecklistItem> {
|
class _ChecklistItemState extends State<ChecklistItem> {
|
||||||
late final TextEditingController _textController;
|
late final TextEditingController _textController;
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode(skipTraversal: true);
|
||||||
|
final FocusNode _textFieldFocusNode = FocusNode();
|
||||||
|
|
||||||
bool _isHovered = false;
|
bool _isHovered = false;
|
||||||
bool _isFocused = false;
|
bool _isFocused = false;
|
||||||
Timer? _debounceOnChanged;
|
Timer? _debounceOnChanged;
|
||||||
@ -184,6 +178,7 @@ class _ChecklistItemState extends State<ChecklistItem> {
|
|||||||
_debounceOnChanged?.cancel();
|
_debounceOnChanged?.cancel();
|
||||||
_textController.dispose();
|
_textController.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
|
_textFieldFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,6 +195,7 @@ class _ChecklistItemState extends State<ChecklistItem> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FocusableActionDetector(
|
return FocusableActionDetector(
|
||||||
|
focusNode: _focusNode,
|
||||||
onShowHoverHighlight: (isHovered) {
|
onShowHoverHighlight: (isHovered) {
|
||||||
setState(() => _isHovered = isHovered);
|
setState(() => _isHovered = isHovered);
|
||||||
},
|
},
|
||||||
@ -212,49 +208,30 @@ class _ChecklistItemState extends State<ChecklistItem> {
|
|||||||
.read<ChecklistCellBloc>()
|
.read<ChecklistCellBloc>()
|
||||||
.add(ChecklistCellEvent.selectTask(widget.task.data.id)),
|
.add(ChecklistCellEvent.selectTask(widget.task.data.id)),
|
||||||
),
|
),
|
||||||
_DeleteTaskIntent: CallbackAction<_DeleteTaskIntent>(
|
|
||||||
onInvoke: (_DeleteTaskIntent intent) => context
|
|
||||||
.read<ChecklistCellBloc>()
|
|
||||||
.add(ChecklistCellEvent.deleteTask(widget.task.data.id)),
|
|
||||||
),
|
|
||||||
_StartEditingTaskIntent: CallbackAction<_StartEditingTaskIntent>(
|
|
||||||
onInvoke: (_StartEditingTaskIntent intent) =>
|
|
||||||
_focusNode.requestFocus(),
|
|
||||||
),
|
|
||||||
_EndEditingTaskIntent: CallbackAction<_EndEditingTaskIntent>(
|
_EndEditingTaskIntent: CallbackAction<_EndEditingTaskIntent>(
|
||||||
onInvoke: (_EndEditingTaskIntent intent) => _focusNode.unfocus(),
|
onInvoke: (_EndEditingTaskIntent intent) =>
|
||||||
|
_textFieldFocusNode.unfocus(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
const SingleActivator(LogicalKeyboardKey.space):
|
SingleActivator(
|
||||||
const _SelectTaskIntent(),
|
LogicalKeyboardKey.enter,
|
||||||
const SingleActivator(LogicalKeyboardKey.delete):
|
meta: Platform.isMacOS,
|
||||||
const _DeleteTaskIntent(),
|
control: !Platform.isMacOS,
|
||||||
const SingleActivator(LogicalKeyboardKey.enter):
|
): const _SelectTaskIntent(),
|
||||||
const _StartEditingTaskIntent(),
|
|
||||||
if (Platform.isMacOS)
|
|
||||||
const SingleActivator(LogicalKeyboardKey.enter, meta: true):
|
|
||||||
const _SelectTaskIntent()
|
|
||||||
else
|
|
||||||
const SingleActivator(LogicalKeyboardKey.enter, control: true):
|
|
||||||
const _SelectTaskIntent(),
|
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowUp):
|
|
||||||
const PreviousFocusIntent(),
|
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowDown):
|
|
||||||
const NextFocusIntent(),
|
|
||||||
},
|
},
|
||||||
descendantsAreTraversable: false,
|
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight),
|
constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _isHovered || _isFocused || _focusNode.hasFocus
|
color: _isHovered || _isFocused || _textFieldFocusNode.hasFocus
|
||||||
? AFThemeExtension.of(context).lightGreyHover
|
? AFThemeExtension.of(context).lightGreyHover
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
borderRadius: Corners.s6Border,
|
borderRadius: Corners.s6Border,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
FlowyIconButton(
|
ExcludeFocus(
|
||||||
|
child: FlowyIconButton(
|
||||||
width: 32,
|
width: 32,
|
||||||
icon: FlowySvg(
|
icon: FlowySvg(
|
||||||
widget.task.isSelected
|
widget.task.isSelected
|
||||||
@ -267,30 +244,18 @@ class _ChecklistItemState extends State<ChecklistItem> {
|
|||||||
ChecklistCellEvent.selectTask(widget.task.data.id),
|
ChecklistCellEvent.selectTask(widget.task.data.id),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Shortcuts(
|
child: Shortcuts(
|
||||||
shortcuts: {
|
shortcuts: const {
|
||||||
const SingleActivator(LogicalKeyboardKey.space):
|
SingleActivator(LogicalKeyboardKey.escape):
|
||||||
const DoNothingAndStopPropagationIntent(),
|
_EndEditingTaskIntent(),
|
||||||
const SingleActivator(LogicalKeyboardKey.delete):
|
|
||||||
const DoNothingAndStopPropagationIntent(),
|
|
||||||
if (Platform.isMacOS)
|
|
||||||
LogicalKeySet(
|
|
||||||
LogicalKeyboardKey.fn,
|
|
||||||
LogicalKeyboardKey.backspace,
|
|
||||||
): const DoNothingAndStopPropagationIntent(),
|
|
||||||
const SingleActivator(LogicalKeyboardKey.enter):
|
|
||||||
const DoNothingAndStopPropagationIntent(),
|
|
||||||
const SingleActivator(LogicalKeyboardKey.escape):
|
|
||||||
const _EndEditingTaskIntent(),
|
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowUp):
|
|
||||||
const DoNothingAndStopPropagationIntent(),
|
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowDown):
|
|
||||||
const DoNothingAndStopPropagationIntent(),
|
|
||||||
},
|
},
|
||||||
child: TextField(
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
return TextField(
|
||||||
controller: _textController,
|
controller: _textController,
|
||||||
focusNode: _focusNode,
|
focusNode: _textFieldFocusNode,
|
||||||
autofocus: widget.autofocus,
|
autofocus: widget.autofocus,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@ -304,6 +269,9 @@ class _ChecklistItemState extends State<ChecklistItem> {
|
|||||||
),
|
),
|
||||||
hintText: LocaleKeys.grid_checklist_taskHint.tr(),
|
hintText: LocaleKeys.grid_checklist_taskHint.tr(),
|
||||||
),
|
),
|
||||||
|
textInputAction: widget.onSubmitted == null
|
||||||
|
? TextInputAction.next
|
||||||
|
: null,
|
||||||
onChanged: (text) {
|
onChanged: (text) {
|
||||||
if (_textController.value.composing.isCollapsed) {
|
if (_textController.value.composing.isCollapsed) {
|
||||||
_debounceOnChangedText(text);
|
_debounceOnChangedText(text);
|
||||||
@ -311,17 +279,19 @@ class _ChecklistItemState extends State<ChecklistItem> {
|
|||||||
},
|
},
|
||||||
onSubmitted: (description) {
|
onSubmitted: (description) {
|
||||||
_submitUpdateTaskDescription(description);
|
_submitUpdateTaskDescription(description);
|
||||||
|
if (widget.onSubmitted != null) {
|
||||||
widget.onSubmitted?.call();
|
widget.onSubmitted?.call();
|
||||||
|
} else {
|
||||||
|
Actions.invoke(context, const NextFocusIntent());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isHovered || _isFocused || _focusNode.hasFocus)
|
if (_isHovered || _isFocused || _textFieldFocusNode.hasFocus)
|
||||||
FlowyIconButton(
|
_DeleteTaskButton(
|
||||||
width: 32,
|
|
||||||
icon: const FlowySvg(FlowySvgs.delete_s),
|
|
||||||
hoverColor: Colors.transparent,
|
|
||||||
iconColorOnHover: Theme.of(context).colorScheme.error,
|
|
||||||
onPressed: () => context.read<ChecklistCellBloc>().add(
|
onPressed: () => context.read<ChecklistCellBloc>().add(
|
||||||
ChecklistCellEvent.deleteTask(widget.task.data.id),
|
ChecklistCellEvent.deleteTask(widget.task.data.id),
|
||||||
),
|
),
|
||||||
@ -440,3 +410,56 @@ class _NewTaskItemState extends State<NewTaskItem> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DeleteTaskButton extends StatefulWidget {
|
||||||
|
const _DeleteTaskButton({
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DeleteTaskButton> createState() => _DeleteTaskButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeleteTaskButtonState extends State<_DeleteTaskButton> {
|
||||||
|
final _materialStatesController = MaterialStatesController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_materialStatesController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextButton(
|
||||||
|
onPressed: widget.onPressed,
|
||||||
|
onHover: (_) => setState(() {}),
|
||||||
|
onFocusChange: (_) => setState(() {}),
|
||||||
|
style: ButtonStyle(
|
||||||
|
fixedSize: const MaterialStatePropertyAll(Size.square(32)),
|
||||||
|
minimumSize: const MaterialStatePropertyAll(Size.square(32)),
|
||||||
|
maximumSize: const MaterialStatePropertyAll(Size.square(32)),
|
||||||
|
overlayColor: MaterialStateProperty.resolveWith((state) {
|
||||||
|
if (state.contains(MaterialState.focused)) {
|
||||||
|
return AFThemeExtension.of(context).greyHover;
|
||||||
|
}
|
||||||
|
return Colors.transparent;
|
||||||
|
}),
|
||||||
|
shape: const MaterialStatePropertyAll(
|
||||||
|
RoundedRectangleBorder(borderRadius: Corners.s6Border),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
statesController: _materialStatesController,
|
||||||
|
child: FlowySvg(
|
||||||
|
FlowySvgs.delete_s,
|
||||||
|
color: _materialStatesController.value
|
||||||
|
.contains(MaterialState.hovered) ||
|
||||||
|
_materialStatesController.value.contains(MaterialState.focused)
|
||||||
|
? Theme.of(context).colorScheme.error
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -3,7 +3,7 @@ import TextField from '@mui/material/TextField';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
|
|
||||||
const urlPattern = /^https?:\/\/.+/;
|
const urlPattern = /^(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm|.webp|.svg)(\?[^\s[",><]*)?$/;
|
||||||
|
|
||||||
export function EmbedLink({
|
export function EmbedLink({
|
||||||
onDone,
|
onDone,
|
||||||
|
@ -22,8 +22,10 @@ export const LocalImage = forwardRef<
|
|||||||
const { readBinaryFile, BaseDirectory } = await import('@tauri-apps/api/fs');
|
const { readBinaryFile, BaseDirectory } = await import('@tauri-apps/api/fs');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const svg = src.endsWith('.svg');
|
||||||
|
|
||||||
const buffer = await readBinaryFile(src, { dir: BaseDirectory.AppLocalData });
|
const buffer = await readBinaryFile(src, { dir: BaseDirectory.AppLocalData });
|
||||||
const blob = new Blob([buffer]);
|
const blob = new Blob([buffer], { type: svg ? 'image/svg+xml' : 'image' });
|
||||||
|
|
||||||
setImageURL(URL.createObjectURL(blob));
|
setImageURL(URL.createObjectURL(blob));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -50,6 +50,7 @@ export interface KeyboardNavigationProps<T> {
|
|||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
itemClassName?: string;
|
itemClassName?: string;
|
||||||
itemStyle?: React.CSSProperties;
|
itemStyle?: React.CSSProperties;
|
||||||
|
renderNoResult?: () => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KeyboardNavigation<T>({
|
function KeyboardNavigation<T>({
|
||||||
@ -69,6 +70,7 @@ function KeyboardNavigation<T>({
|
|||||||
onFocus,
|
onFocus,
|
||||||
itemClassName,
|
itemClassName,
|
||||||
itemStyle,
|
itemStyle,
|
||||||
|
renderNoResult,
|
||||||
}: KeyboardNavigationProps<T>) {
|
}: KeyboardNavigationProps<T>) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@ -301,6 +303,8 @@ function KeyboardNavigation<T>({
|
|||||||
>
|
>
|
||||||
{options.length > 0 ? (
|
{options.length > 0 ? (
|
||||||
options.map(renderOption)
|
options.map(renderOption)
|
||||||
|
) : renderNoResult ? (
|
||||||
|
renderNoResult()
|
||||||
) : (
|
) : (
|
||||||
<Typography variant='body1' className={'p-3 text-xs text-text-caption'}>
|
<Typography variant='body1' className={'p-3 text-xs text-text-caption'}>
|
||||||
{t('findAndReplace.noResult')}
|
{t('findAndReplace.noResult')}
|
||||||
|
@ -4,7 +4,17 @@ import { useViewId } from '$app/hooks';
|
|||||||
import { Button } from '@mui/material';
|
import { Button } from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
function AddNewOption({ rowId, fieldId, onClose }: { rowId: string; fieldId: string; onClose: () => void }) {
|
function AddNewOption({
|
||||||
|
rowId,
|
||||||
|
fieldId,
|
||||||
|
onClose,
|
||||||
|
onFocus,
|
||||||
|
}: {
|
||||||
|
rowId: string;
|
||||||
|
fieldId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
@ -18,6 +28,7 @@ function AddNewOption({ rowId, fieldId, onClose }: { rowId: string; fieldId: str
|
|||||||
return (
|
return (
|
||||||
<div className={'flex items-center justify-between p-2 px-2 text-sm'}>
|
<div className={'flex items-center justify-between p-2 px-2 text-sm'}>
|
||||||
<input
|
<input
|
||||||
|
onFocus={onFocus}
|
||||||
placeholder={t('grid.checklist.addNew')}
|
placeholder={t('grid.checklist.addNew')}
|
||||||
className={'flex-1 px-2'}
|
className={'flex-1 px-2'}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
|
@ -19,26 +19,15 @@ function ChecklistCellActions({
|
|||||||
const { fieldId, rowId } = cell;
|
const { fieldId, rowId } = cell;
|
||||||
const { percentage, selectedOptions = [], options = [] } = cell.data;
|
const { percentage, selectedOptions = [], options = [] } = cell.data;
|
||||||
|
|
||||||
const [hoverId, setHoverId] = useState<string | null>(null);
|
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover {...props} disableRestoreFocus={true}>
|
||||||
{...props}
|
|
||||||
disableRestoreFocus={true}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
props.onClose?.({}, 'escapeKeyDown');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
maxHeight: maxHeight,
|
maxHeight: maxHeight,
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => setHoverId(null)}
|
|
||||||
className={'flex h-full w-full flex-col overflow-hidden'}
|
className={'flex h-full w-full flex-col overflow-hidden'}
|
||||||
>
|
>
|
||||||
{options.length > 0 && (
|
{options.length > 0 && (
|
||||||
@ -56,10 +45,10 @@ function ChecklistCellActions({
|
|||||||
<ChecklistItem
|
<ChecklistItem
|
||||||
fieldId={fieldId}
|
fieldId={fieldId}
|
||||||
rowId={rowId}
|
rowId={rowId}
|
||||||
isHovered={hoverId === option.id}
|
isSelected={focusedId === option.id}
|
||||||
onMouseEnter={() => setHoverId(option.id)}
|
|
||||||
key={option.id}
|
key={option.id}
|
||||||
option={option}
|
option={option}
|
||||||
|
onFocus={() => setFocusedId(option.id)}
|
||||||
onClose={() => props.onClose?.({}, 'escapeKeyDown')}
|
onClose={() => props.onClose?.({}, 'escapeKeyDown')}
|
||||||
checked={selectedOptions?.includes(option.id) || false}
|
checked={selectedOptions?.includes(option.id) || false}
|
||||||
/>
|
/>
|
||||||
@ -71,7 +60,14 @@ function ChecklistCellActions({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AddNewOption onClose={() => props.onClose?.({}, 'escapeKeyDown')} fieldId={fieldId} rowId={rowId} />
|
<AddNewOption
|
||||||
|
onFocus={() => {
|
||||||
|
setFocusedId(null);
|
||||||
|
}}
|
||||||
|
onClose={() => props.onClose?.({}, 'escapeKeyDown')}
|
||||||
|
fieldId={fieldId}
|
||||||
|
rowId={rowId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
@ -18,17 +18,18 @@ function ChecklistItem({
|
|||||||
rowId,
|
rowId,
|
||||||
fieldId,
|
fieldId,
|
||||||
onClose,
|
onClose,
|
||||||
isHovered,
|
isSelected,
|
||||||
onMouseEnter,
|
onFocus,
|
||||||
}: {
|
}: {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
option: SelectOption;
|
option: SelectOption;
|
||||||
rowId: string;
|
rowId: string;
|
||||||
fieldId: string;
|
fieldId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isHovered: boolean;
|
isSelected: boolean;
|
||||||
onMouseEnter: () => void;
|
onFocus: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState(option.name);
|
const [value, setValue] = useState(option.name);
|
||||||
const viewId = useViewId();
|
const viewId = useViewId();
|
||||||
@ -61,17 +62,23 @@ function ChecklistItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onMouseEnter={onMouseEnter}
|
style={{
|
||||||
className={`flex items-center justify-between gap-2 rounded p-1 text-sm hover:bg-fill-list-active`}
|
backgroundColor: isSelected ? 'var(--fill-list-active)' : undefined,
|
||||||
|
}}
|
||||||
|
className={`checklist-item ${
|
||||||
|
isSelected ? 'selected' : ''
|
||||||
|
} flex items-center justify-between gap-2 rounded p-1 text-sm hover:bg-fill-list-hover`}
|
||||||
>
|
>
|
||||||
<div className={'cursor-pointer select-none text-content-blue-400'} onClick={onCheckedChange}>
|
<div className={'relative cursor-pointer select-none text-content-blue-400'} onClick={onCheckedChange}>
|
||||||
{checked ? <CheckboxCheckSvg className={'h-5 w-5'} /> : <CheckboxUncheckSvg className={'h-5 w-5'} />}
|
{checked ? <CheckboxCheckSvg className={'h-5 w-5'} /> : <CheckboxUncheckSvg className={'h-5 w-5'} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
className={'flex-1 truncate'}
|
className={'flex-1 truncate'}
|
||||||
|
ref={inputRef}
|
||||||
onBlur={updateText}
|
onBlur={updateText}
|
||||||
value={value}
|
value={value}
|
||||||
|
onFocus={onFocus}
|
||||||
placeholder={t('grid.checklist.taskHint')}
|
placeholder={t('grid.checklist.taskHint')}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@ -99,14 +106,7 @@ function ChecklistItem({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className={'w-10'}>
|
<div className={'w-10'}>
|
||||||
<IconButton
|
<IconButton size={'small'} className={`delete-option-button z-10 mx-2`} onClick={deleteOption}>
|
||||||
size={'small'}
|
|
||||||
style={{
|
|
||||||
display: isHovered ? 'block' : 'none',
|
|
||||||
}}
|
|
||||||
className={`z-10 mx-2`}
|
|
||||||
onClick={deleteOption}
|
|
||||||
>
|
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
@ -118,6 +118,9 @@ export const SelectOptionModifyMenu: FC<SelectOptionMenuProps> = ({ fieldId, opt
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
placeholder={t('grid.selectOption.tagName')}
|
placeholder={t('grid.selectOption.tagName')}
|
||||||
size='small'
|
size='small'
|
||||||
|
@ -54,7 +54,7 @@ function SelectCellActions({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (result.length === 0 && newOptionName) {
|
||||||
result.push({
|
result.push({
|
||||||
key: CREATE_OPTION_KEY,
|
key: CREATE_OPTION_KEY,
|
||||||
content: <Tag size='small' label={newOptionName} />,
|
content: <Tag size='small' label={newOptionName} />,
|
||||||
@ -69,8 +69,7 @@ function SelectCellActions({
|
|||||||
const updateCell = useCallback(
|
const updateCell = useCallback(
|
||||||
async (optionIds: string[]) => {
|
async (optionIds: string[]) => {
|
||||||
if (!cell || !rowId) return;
|
if (!cell || !rowId) return;
|
||||||
const prev = selectedOptionIds;
|
const deleteOptionIds = selectedOptionIds?.filter((id) => optionIds.find((cur) => cur === id) === undefined);
|
||||||
const deleteOptionIds = prev?.filter((id) => optionIds.find((cur) => cur === id) === undefined);
|
|
||||||
|
|
||||||
await cellService.updateSelectCell(viewId, rowId, field.id, {
|
await cellService.updateSelectCell(viewId, rowId, field.id, {
|
||||||
insertOptionIds: optionIds,
|
insertOptionIds: optionIds,
|
||||||
@ -136,9 +135,12 @@ function SelectCellActions({
|
|||||||
<div className={'flex h-full flex-col overflow-hidden'}>
|
<div className={'flex h-full flex-col overflow-hidden'}>
|
||||||
<SearchInput inputRef={inputRef} setNewOptionName={setNewOptionName} newOptionName={newOptionName} />
|
<SearchInput inputRef={inputRef} setNewOptionName={setNewOptionName} newOptionName={newOptionName} />
|
||||||
|
|
||||||
|
{filteredOptions.length > 0 && (
|
||||||
<div className='mx-4 mb-2 mt-4 text-xs'>
|
<div className='mx-4 mb-2 mt-4 text-xs'>
|
||||||
{shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')}
|
{shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div ref={scrollRef} className={'mx-1 flex-1 overflow-y-auto overflow-x-hidden px-1'}>
|
<div ref={scrollRef} className={'mx-1 flex-1 overflow-y-auto overflow-x-hidden px-1'}>
|
||||||
<KeyboardNavigation
|
<KeyboardNavigation
|
||||||
scrollRef={scrollRef}
|
scrollRef={scrollRef}
|
||||||
@ -150,6 +152,7 @@ function SelectCellActions({
|
|||||||
itemStyle={{
|
itemStyle={{
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
}}
|
}}
|
||||||
|
renderNoResult={() => null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { Popover, TextareaAutosize } from '@mui/material';
|
import { Popover, TextareaAutosize } from '@mui/material';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -8,6 +8,7 @@ interface Props {
|
|||||||
text: string;
|
text: string;
|
||||||
onInput: (event: React.FormEvent<HTMLTextAreaElement>) => void;
|
onInput: (event: React.FormEvent<HTMLTextAreaElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props) {
|
function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props) {
|
||||||
const handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
const shift = e.shiftKey;
|
const shift = e.shiftKey;
|
||||||
@ -20,6 +21,13 @@ function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setRef = useCallback((e: HTMLTextAreaElement | null) => {
|
||||||
|
if (!e) return;
|
||||||
|
const selectionStart = e.value.length;
|
||||||
|
|
||||||
|
e.setSelectionRange(selectionStart, selectionStart);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
open={editing}
|
open={editing}
|
||||||
@ -47,6 +55,7 @@ function EditTextCellInput({ editing, anchorEl, onClose, text, onInput }: Props)
|
|||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
className='w-full resize-none whitespace-break-spaces break-all text-sm'
|
className='w-full resize-none whitespace-break-spaces break-all text-sm'
|
||||||
autoFocus
|
autoFocus
|
||||||
|
ref={setRef}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
autoCorrect='off'
|
autoCorrect='off'
|
||||||
value={text}
|
value={text}
|
||||||
|
@ -4,3 +4,16 @@
|
|||||||
height: 0px;
|
height: 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checklist-item {
|
||||||
|
@apply my-1;
|
||||||
|
.delete-option-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&:hover, &.selected {
|
||||||
|
background-color: var(--fill-list-hover);
|
||||||
|
.delete-option-button {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,11 @@ import { CustomEditor } from '$app/components/editor/command';
|
|||||||
import { useSlateStatic } from 'slate-react';
|
import { useSlateStatic } from 'slate-react';
|
||||||
import ImageActions from '$app/components/editor/components/blocks/image/ImageActions';
|
import ImageActions from '$app/components/editor/components/blocks/image/ImageActions';
|
||||||
import { LocalImage } from '$app/components/_shared/image_upload';
|
import { LocalImage } from '$app/components/_shared/image_upload';
|
||||||
|
import debounce from 'lodash-es/debounce';
|
||||||
|
|
||||||
|
const MIN_WIDTH = 100;
|
||||||
|
|
||||||
|
const DELAY = 300;
|
||||||
|
|
||||||
function ImageRender({ selected, node }: { selected: boolean; node: ImageNode }) {
|
function ImageRender({ selected, node }: { selected: boolean; node: ImageNode }) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -21,14 +26,22 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
|
|||||||
|
|
||||||
const [showActions, setShowActions] = useState(false);
|
const [showActions, setShowActions] = useState(false);
|
||||||
const [initialWidth, setInitialWidth] = useState<number | null>(null);
|
const [initialWidth, setInitialWidth] = useState<number | null>(null);
|
||||||
|
const [newWidth, setNewWidth] = useState<number | null>(imageWidth ?? null);
|
||||||
|
|
||||||
const handleWidthChange = useCallback(
|
const debounceSubmitWidth = useMemo(() => {
|
||||||
(newWidth: number) => {
|
return debounce((newWidth: number) => {
|
||||||
CustomEditor.setImageBlockData(editor, node, {
|
CustomEditor.setImageBlockData(editor, node, {
|
||||||
width: newWidth,
|
width: newWidth,
|
||||||
});
|
});
|
||||||
|
}, DELAY);
|
||||||
|
}, [editor, node]);
|
||||||
|
|
||||||
|
const handleWidthChange = useCallback(
|
||||||
|
(newWidth: number) => {
|
||||||
|
setNewWidth(newWidth);
|
||||||
|
debounceSubmitWidth(newWidth);
|
||||||
},
|
},
|
||||||
[editor, node]
|
[debounceSubmitWidth]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -38,7 +51,7 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
|
|||||||
}, [hasError, initialWidth, loading]);
|
}, [hasError, initialWidth, loading]);
|
||||||
const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => {
|
const imageProps: React.ImgHTMLAttributes<HTMLImageElement> = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
style: { width: loading || hasError ? '0' : imageWidth ?? '100%', opacity: selected ? 0.8 : 1 },
|
style: { width: loading || hasError ? '0' : newWidth ?? '100%', opacity: selected ? 0.8 : 1 },
|
||||||
className: 'object-cover',
|
className: 'object-cover',
|
||||||
ref: imgRef,
|
ref: imgRef,
|
||||||
src: url,
|
src: url,
|
||||||
@ -52,7 +65,7 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [url, imageWidth, loading, hasError, selected]);
|
}, [url, newWidth, loading, hasError, selected]);
|
||||||
|
|
||||||
const renderErrorNode = useCallback(() => {
|
const renderErrorNode = useCallback(() => {
|
||||||
return (
|
return (
|
||||||
@ -75,7 +88,13 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
|
|||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
setShowActions(false);
|
setShowActions(false);
|
||||||
}}
|
}}
|
||||||
className={`relative min-h-[48px] ${hasError || (loading && source !== ImageType.Local) ? 'w-full' : ''}`}
|
style={{
|
||||||
|
minWidth: MIN_WIDTH,
|
||||||
|
width: 'fit-content',
|
||||||
|
}}
|
||||||
|
className={`image-render relative min-h-[48px] ${
|
||||||
|
hasError || (loading && source !== ImageType.Local) ? 'w-full' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{source === ImageType.Local ? (
|
{source === ImageType.Local ? (
|
||||||
<LocalImage
|
<LocalImage
|
||||||
@ -90,7 +109,17 @@ function ImageRender({ selected, node }: { selected: boolean; node: ImageNode })
|
|||||||
<img loading={'lazy'} {...imageProps} alt={`image-${blockId}`} />
|
<img loading={'lazy'} {...imageProps} alt={`image-${blockId}`} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{initialWidth && <ImageResizer width={imageWidth ?? initialWidth} onWidthChange={handleWidthChange} />}
|
{initialWidth && (
|
||||||
|
<>
|
||||||
|
<ImageResizer
|
||||||
|
isLeft
|
||||||
|
minWidth={MIN_WIDTH}
|
||||||
|
width={imageWidth ?? initialWidth}
|
||||||
|
onWidthChange={handleWidthChange}
|
||||||
|
/>
|
||||||
|
<ImageResizer minWidth={MIN_WIDTH} width={imageWidth ?? initialWidth} onWidthChange={handleWidthChange} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{showActions && <ImageActions node={node} />}
|
{showActions && <ImageActions node={node} />}
|
||||||
{hasError ? (
|
{hasError ? (
|
||||||
renderErrorNode()
|
renderErrorNode()
|
||||||
|
@ -1,24 +1,32 @@
|
|||||||
import React, { useCallback, useRef } from 'react';
|
import React, { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
const MIN_WIDTH = 80;
|
function ImageResizer({
|
||||||
|
minWidth,
|
||||||
function ImageResizer({ width, onWidthChange }: { width: number; onWidthChange: (newWidth: number) => void }) {
|
width,
|
||||||
|
onWidthChange,
|
||||||
|
isLeft,
|
||||||
|
}: {
|
||||||
|
isLeft?: boolean;
|
||||||
|
minWidth: number;
|
||||||
|
width: number;
|
||||||
|
onWidthChange: (newWidth: number) => void;
|
||||||
|
}) {
|
||||||
const originalWidth = useRef(width);
|
const originalWidth = useRef(width);
|
||||||
const startX = useRef(0);
|
const startX = useRef(0);
|
||||||
|
|
||||||
const onResize = useCallback(
|
const onResize = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const diff = e.clientX - startX.current;
|
const diff = isLeft ? startX.current - e.clientX : e.clientX - startX.current;
|
||||||
const newWidth = originalWidth.current + diff;
|
const newWidth = originalWidth.current + diff;
|
||||||
|
|
||||||
if (newWidth < MIN_WIDTH) {
|
if (newWidth < minWidth) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onWidthChange(newWidth);
|
onWidthChange(newWidth);
|
||||||
},
|
},
|
||||||
[onWidthChange]
|
[isLeft, minWidth, onWidthChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onResizeEnd = useCallback(() => {
|
const onResizeEnd = useCallback(() => {
|
||||||
@ -40,7 +48,8 @@ function ImageResizer({ width, onWidthChange }: { width: number; onWidthChange:
|
|||||||
<div
|
<div
|
||||||
onMouseDown={onResizeStart}
|
onMouseDown={onResizeStart}
|
||||||
style={{
|
style={{
|
||||||
right: '2px',
|
right: isLeft ? 'auto' : '2px',
|
||||||
|
left: isLeft ? '-2px' : 'auto',
|
||||||
}}
|
}}
|
||||||
className={'image-resizer'}
|
className={'image-resizer'}
|
||||||
>
|
>
|
||||||
|
@ -44,6 +44,16 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul
|
|||||||
|
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
|
|
||||||
|
let isComposing = false;
|
||||||
|
|
||||||
|
const handleCompositionUpdate = () => {
|
||||||
|
isComposing = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompositionEnd = () => {
|
||||||
|
isComposing = false;
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
@ -69,16 +79,22 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
if (!isComposing && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||||
notify.clear();
|
notify.clear();
|
||||||
notify.info(`Press Tab to focus on the menu`);
|
notify.info(`Press Tab to focus on the menu`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
input.addEventListener('compositionstart', handleCompositionUpdate);
|
||||||
|
input.addEventListener('compositionend', handleCompositionEnd);
|
||||||
|
input.addEventListener('compositionupdate', handleCompositionUpdate);
|
||||||
input.addEventListener('keydown', handleKeyDown);
|
input.addEventListener('keydown', handleKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
input.removeEventListener('keydown', handleKeyDown);
|
input.removeEventListener('keydown', handleKeyDown);
|
||||||
|
input.removeEventListener('compositionstart', handleCompositionUpdate);
|
||||||
|
input.removeEventListener('compositionend', handleCompositionEnd);
|
||||||
|
input.removeEventListener('compositionupdate', handleCompositionUpdate);
|
||||||
};
|
};
|
||||||
}, [link, onClose, setNodeMark]);
|
}, [link, onClose, setNodeMark]);
|
||||||
|
|
||||||
|
@ -138,18 +138,23 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
|
|||||||
height: 0;
|
height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-render {
|
||||||
.image-resizer {
|
.image-resizer {
|
||||||
@apply absolute w-[10px] top-0 z-10 flex h-full cursor-col-resize items-center justify-end;
|
@apply absolute w-[10px] top-0 z-10 flex h-full cursor-col-resize items-center justify-end;
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
@apply h-1/4 w-1/2 transform transition-all duration-500 select-none rounded-full border border-white opacity-0;
|
@apply h-1/4 w-1/2 transform transition-all duration-500 select-none rounded-full border border-white opacity-0;
|
||||||
background: var(--fill-toolbar);
|
background: var(--fill-toolbar);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
&:hover {
|
&:hover {
|
||||||
|
.image-resizer{
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
@apply opacity-90;
|
@apply opacity-90;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.image-block, .math-equation-block, [data-dark-mode="true"] .image-block, [data-dark-mode="true"] .math-equation-block {
|
.image-block, .math-equation-block, [data-dark-mode="true"] .image-block, [data-dark-mode="true"] .math-equation-block {
|
||||||
::selection {
|
::selection {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png'];
|
export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'];
|
||||||
export const IMAGE_DIR = 'images';
|
export const IMAGE_DIR = 'images';
|
||||||
|
|
||||||
export function getFileName(url: string) {
|
export function getFileName(url: string) {
|
||||||
|
@ -921,7 +921,7 @@
|
|||||||
"error": {
|
"error": {
|
||||||
"invalidImage": "Invalid image",
|
"invalidImage": "Invalid image",
|
||||||
"invalidImageSize": "Image size must be less than 5MB",
|
"invalidImageSize": "Image size must be less than 5MB",
|
||||||
"invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG",
|
"invalidImageFormat": "Image format is not supported. Supported formats: JPEG, PNG, JPG, GIF, SVG, WEBP",
|
||||||
"invalidImageUrl": "Invalid image URL",
|
"invalidImageUrl": "Invalid image URL",
|
||||||
"noImage": "No such file or directory"
|
"noImage": "No such file or directory"
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user