refactor: remove all legacy files

+ comment/delete things that haven't yet migrated to mantine
This commit is contained in:
Sonny
2024-11-07 00:29:58 +01:00
committed by Sonny
parent 861906d29b
commit 5c37fe9c31
148 changed files with 469 additions and 4728 deletions

View File

@@ -1,3 +0,0 @@
{
"plugins": ["@emotion"]
}

View File

@@ -31,16 +31,22 @@ export default class AdminController {
protected collectionsController: CollectionsController
) {}
async index({ inertia }: HttpContext) {
async index({ response }: HttpContext) {
const users = await this.usersController.getAllUsersWithTotalRelations();
const linksCount = await this.linksController.getTotalLinksCount();
const collectionsCount =
await this.collectionsController.getTotalCollectionsCount();
return inertia.render('admin/dashboard', {
// TODO: return view
return response.json({
users: users.map((user) => new UserWithRelationCountDto(user).toJson()),
totalLinks: linksCount,
totalCollections: collectionsCount,
});
// return inertia.render('admin/dashboard', {
// users: users.map((user) => new UserWithRelationCountDto(user).toJson()),
// totalLinks: linksCount,
// totalCollections: collectionsCount,
// });
}
}

View File

@@ -26,7 +26,7 @@ export default class CollectionsController {
}
// TODO: Create DTOs
return inertia.render('mantine_dashboard', {
return inertia.render('dashboard', {
collections: collections.map((collection) => collection.serialize()),
activeCollection:
activeCollection?.serialize() || collections[0].serialize(),
@@ -36,7 +36,7 @@ export default class CollectionsController {
// Create collection form
async showCreatePage({ inertia, auth }: HttpContext) {
const collections = await this.getCollectionsByAuthorId(auth.user!.id);
return inertia.render('mantine/collections/create', {
return inertia.render('collections/create', {
disableHomeLink: collections.length === 0,
});
}
@@ -61,7 +61,7 @@ export default class CollectionsController {
collectionId,
auth.user!.id
);
return inertia.render('mantine/collections/edit', {
return inertia.render('collections/edit', {
collection,
});
}
@@ -96,7 +96,7 @@ export default class CollectionsController {
collectionId,
auth.user!.id
);
return inertia.render('mantine/collections/delete', {
return inertia.render('collections/delete', {
collection,
});
}

View File

@@ -17,7 +17,7 @@ export default class LinksController {
async showCreatePage({ auth, inertia }: HttpContext) {
const collections =
await this.collectionsController.getCollectionsByAuthorId(auth.user!.id);
return inertia.render('mantine/links/create', { collections });
return inertia.render('links/create', { collections });
}
async store({ auth, request, response }: HttpContext) {
@@ -50,7 +50,7 @@ export default class LinksController {
await this.collectionsController.getCollectionsByAuthorId(userId);
const link = await this.getLinkById(linkId, userId);
return inertia.render('mantine/links/edit', { collections, link });
return inertia.render('links/edit', { collections, link });
}
async update({ request, auth, response }: HttpContext) {
@@ -98,7 +98,7 @@ export default class LinksController {
const link = await this.getLinkById(linkId, auth.user!.id);
await link.load('collection');
return inertia.render('mantine/links/delete', { link });
return inertia.render('links/delete', { link });
}
async delete({ request, auth, response }: HttpContext) {

View File

@@ -4,13 +4,16 @@ import { getSharedCollectionValidator } from '#validators/shared_collection';
import type { HttpContext } from '@adonisjs/core/http';
export default class SharedCollectionsController {
async index({ request, inertia }: HttpContext) {
async index({ request, response }: HttpContext) {
const { params } = await request.validateUsing(
getSharedCollectionValidator
);
const collection = await this.getSharedCollectionById(params.id);
return inertia.render('shared', { collection });
console.log('shared page', collection);
// TODO: return view
return response.json(collection);
// return inertia.render('shared', { collection });
}
private async getSharedCollectionById(id: Collection['id']) {

View File

@@ -3,14 +3,12 @@ import { createInertiaApp } from '@inertiajs/react';
import 'dayjs/locale/en';
import 'dayjs/locale/fr';
import { hydrateRoot } from 'react-dom/client';
import 'react-toggle/style.css';
import { primaryColor } from '~/styles/common_colors';
import '../i18n/index';
const appName = import.meta.env.VITE_APP_NAME || 'MyLinks';
createInertiaApp({
progress: { color: primaryColor },
progress: { color: '#5474B4' },
title: (title) => `${appName}${title && ` - ${title}`}`,

View File

@@ -1,64 +0,0 @@
import styled from '@emotion/styled';
import { HtmlHTMLAttributes, ReactNode, useRef } from 'react';
import DropdownContainer from '~/components/common/dropdown/dropdown_container';
import DropdownLabel from '~/components/common/dropdown/dropdown_label';
import useClickOutside from '~/hooks/use_click_outside';
import useToggle from '~/hooks/use_modal';
import useShortcut from '~/hooks/use_shortcut';
const DropdownStyle = styled.div<{ opened: boolean; svgSize?: number }>(
({ opened, theme, svgSize = 24 }) => ({
cursor: 'pointer',
userSelect: 'none',
position: 'relative',
minWidth: 'fit-content',
width: 'fit-content',
maxWidth: '250px',
backgroundColor: opened ? theme.colors.secondary : theme.colors.background,
padding: '4px',
borderRadius: theme.border.radius,
'&:hover': {
backgroundColor: theme.colors.secondary,
},
'& svg': {
height: `${svgSize}px`,
width: `${svgSize}px`,
},
})
);
export default function Dropdown({
children,
label,
className,
svgSize,
onClick,
}: HtmlHTMLAttributes<HTMLDivElement> & {
label: ReactNode | string;
className?: string;
svgSize?: number;
}) {
const dropdownRef = useRef<HTMLDivElement>(null);
const { isShowing, toggle, close } = useToggle();
useClickOutside(dropdownRef, close);
useShortcut('ESCAPE_KEY', close, { disableGlobalCheck: true });
return (
<DropdownStyle
opened={isShowing}
onClick={(event) => {
onClick?.(event);
toggle();
}}
ref={dropdownRef}
className={className}
svgSize={svgSize}
>
<DropdownLabel>{label}</DropdownLabel>
<DropdownContainer show={isShowing}>{children}</DropdownContainer>
</DropdownStyle>
);
}

View File

@@ -1,21 +0,0 @@
import styled from '@emotion/styled';
import TransitionLayout from '~/components/layouts/_transition_layout';
const DropdownContainer = styled(TransitionLayout)<{ show: boolean }>(
({ show, theme }) => ({
zIndex: 99,
position: 'absolute',
top: 'calc(100% + 0.5em)',
right: 0,
minWidth: '175px',
backgroundColor: show ? theme.colors.secondary : theme.colors.background,
border: `2px solid ${theme.colors.secondary}`,
borderRadius: theme.border.radius,
boxShadow: theme.colors.boxShadow,
display: show ? 'flex' : 'none',
flexDirection: 'column',
overflow: 'hidden',
})
);
export default DropdownContainer;

View File

@@ -1,31 +0,0 @@
import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
const DropdownItemBase = styled('div', {
shouldForwardProp: (propName) => propName !== 'danger',
})<{ danger?: boolean }>(({ theme, danger }) => ({
fontSize: '14px',
whiteSpace: 'nowrap',
color: danger ? theme.colors.lightRed : theme.colors.primary,
padding: '8px 12px',
borderRadius: theme.border.radius,
'&:hover': {
backgroundColor: theme.colors.background,
},
}));
const DropdownItemButton = styled(DropdownItemBase)({
display: 'flex',
gap: '0.75em',
alignItems: 'center',
});
const DropdownItemLink = styled(DropdownItemBase.withComponent(Link))({
width: '100%',
display: 'flex',
gap: '0.75em',
alignItems: 'center',
});
export { DropdownItemButton, DropdownItemLink };

View File

@@ -1,11 +0,0 @@
import styled from '@emotion/styled';
const DropdownLabel = styled.div(({ theme }) => ({
height: 'auto',
width: 'auto',
color: theme.colors.font,
display: 'flex',
gap: '0.35em',
}));
export default DropdownLabel;

View File

@@ -1,31 +0,0 @@
import styled from '@emotion/styled';
const Button = styled.button<{ danger?: boolean }>(({ theme, danger }) => {
const btnColor = !danger ? theme.colors.primary : theme.colors.lightRed;
const btnDarkColor = !danger ? theme.colors.darkBlue : theme.colors.lightRed;
return {
cursor: 'pointer',
width: '100%',
textTransform: 'uppercase',
fontSize: '14px',
color: theme.colors.white,
background: btnColor,
padding: '0.75em',
border: `1px solid ${btnColor}`,
borderRadius: theme.border.radius,
transition: theme.transition.delay,
'&:disabled': {
cursor: 'not-allowed',
opacity: '0.5',
},
'&:not(:disabled):hover': {
boxShadow: `${btnDarkColor} 0 0 3px 1px`,
background: btnDarkColor,
color: theme.colors.white,
},
};
});
export default Button;

View File

@@ -1,10 +0,0 @@
import styled from '@emotion/styled';
const Form = styled.form({
width: '100%',
display: 'flex',
gap: '1em',
flexDirection: 'column',
});
export default Form;

View File

@@ -1,25 +0,0 @@
import styled from '@emotion/styled';
const FormField = styled('div', {
shouldForwardProp: (propName) => propName !== 'required',
})<{ required?: boolean }>(({ required, theme }) => ({
display: 'flex',
gap: '0.25em',
flexDirection: 'column',
'& label': {
position: 'relative',
userSelect: 'none',
width: 'fit-content',
},
'& label::after': {
position: 'absolute',
top: 0,
right: '-0.75em',
color: theme.colors.lightRed,
content: (required ? '"*"' : '""') as any,
},
}));
export default FormField;

View File

@@ -1,9 +0,0 @@
import styled from '@emotion/styled';
// TODO: create a global style variable (fontSize)
const FormFieldError = styled.p(({ theme }) => ({
fontSize: '12px',
color: theme.colors.lightRed,
}));
export default FormFieldError;

View File

@@ -1,27 +0,0 @@
import styled from '@emotion/styled';
const Input = styled.input(({ theme }) => ({
width: '100%',
color: theme.colors.font,
backgroundColor: theme.colors.secondary,
padding: '0.75em',
border: `1px solid ${theme.colors.lightGrey}`,
borderBottom: `2px solid ${theme.colors.lightGrey}`,
borderRadius: theme.border.radius,
transition: theme.transition.delay,
'&:focus': {
borderBottom: `2px solid ${theme.colors.primary}`,
},
'&:disabled': {
opacity: 0.85,
},
'&::placeholder': {
fontStyle: 'italic',
color: theme.colors.grey,
},
}));
export default Input;

View File

@@ -1,55 +0,0 @@
import { ChangeEvent, Fragment, InputHTMLAttributes, useState } from 'react';
import Toggle from 'react-toggle';
import FormField from '~/components/common/form/_form_field';
import FormFieldError from '~/components/common/form/_form_field_error';
interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
label: string;
name: string;
checked: boolean;
errors?: string[];
onChange?: (name: string, checked: boolean) => void;
}
export default function Checkbox({
name,
label,
checked = false,
errors = [],
onChange,
required = false,
...props
}: InputProps): JSX.Element {
const [checkboxChecked, setCheckboxChecked] = useState<boolean>(checked);
if (typeof window === 'undefined') return <Fragment />;
function _onChange({ target }: ChangeEvent<HTMLInputElement>) {
setCheckboxChecked(target.checked);
if (onChange) {
onChange(target.name, target.checked);
}
}
return (
<FormField
css={{ alignItems: 'center', gap: '1em', flexDirection: 'row' }}
required={required}
>
<label htmlFor={name} title={label}>
{label}
</label>
<Toggle
{...props}
onChange={_onChange}
checked={checkboxChecked}
placeholder={props.placeholder ?? 'Type something...'}
name={name}
id={name}
/>
{errors.length > 0 &&
errors.map((error) => <FormFieldError>{error}</FormFieldError>)}
</FormField>
);
}

View File

@@ -1,79 +0,0 @@
import { useTheme } from '@emotion/react';
import { InputHTMLAttributes, ReactNode, useEffect, useState } from 'react';
import Select, {
FormatOptionLabelMeta,
GroupBase,
OptionsOrGroups,
} from 'react-select';
import FormField from '~/components/common/form/_form_field';
type Option = { label: string | number; value: string | number };
interface SelectorProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
label: string;
name: string;
errors?: string[];
options: OptionsOrGroups<Option, GroupBase<Option>>;
value: number | string;
onChangeCallback?: (value: number | string) => void;
formatOptionLabel?: (
data: Option,
formatOptionLabelMeta: FormatOptionLabelMeta<Option>
) => ReactNode;
}
export default function Selector({
name,
label,
value,
errors = [],
options,
onChangeCallback,
formatOptionLabel,
required = false,
...props
}: SelectorProps): JSX.Element {
const theme = useTheme();
const [selectorValue, setSelectorValue] = useState<Option>();
useEffect(() => {
if (options.length === 0) return;
const option = options.find((o: any) => o.value === value);
if (option) {
setSelectorValue(option as Option);
}
}, [options, value]);
const handleChange = (selectedOption: Option) => {
setSelectorValue(selectedOption);
if (onChangeCallback) {
onChangeCallback(selectedOption.value);
}
};
return (
<FormField required={required}>
{label && (
<label htmlFor={name} title={`${name} field`}>
{label}
</label>
)}
<Select
value={selectorValue}
onChange={(newValue) => handleChange(newValue as Option)}
options={options}
isDisabled={props.disabled}
menuPlacement="auto"
formatOptionLabel={
formatOptionLabel
? (val, formatOptionLabelMeta) =>
formatOptionLabel(val, formatOptionLabelMeta)
: undefined
}
css={{ color: theme.colors.black }}
/>
</FormField>
);
}

View File

@@ -1,49 +0,0 @@
import { ChangeEvent, InputHTMLAttributes, useState } from 'react';
import FormField from '~/components/common/form/_form_field';
import FormFieldError from '~/components/common/form/_form_field_error';
import Input from '~/components/common/form/_input';
interface InputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
label: string;
name: string;
value?: string;
errors?: string[];
onChange?: (name: string, value: string) => void;
}
export default function TextBox({
name,
label,
value = '',
errors = [],
onChange,
required = false,
...props
}: InputProps): JSX.Element {
const [inputValue, setInputValue] = useState<string>(value);
function _onChange({ target }: ChangeEvent<HTMLInputElement>) {
setInputValue(target.value);
if (onChange) {
onChange(target.name, target.value);
}
}
return (
<FormField required={required}>
<label htmlFor={name} title={label}>
{label}
</label>
<Input
{...props}
name={name}
onChange={_onChange}
value={inputValue}
placeholder={props.placeholder ?? 'Type something...'}
/>
{errors.length > 0 &&
errors.map((error) => <FormFieldError>{error}</FormFieldError>)}
</FormField>
);
}

View File

@@ -1,22 +0,0 @@
import styled from '@emotion/styled';
const IconButton = styled.button(({ theme }) => ({
cursor: 'pointer',
height: '2rem',
width: '2rem',
fontSize: '1rem',
color: theme.colors.font,
backgroundColor: theme.colors.grey,
borderRadius: '50%',
border: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:disabled': {
cursor: 'not-allowed',
opacity: 0.15,
},
}));
export default IconButton;

View File

@@ -1,8 +0,0 @@
import styled from '@emotion/styled';
const Legend = styled.span(({ theme }) => ({
fontSize: '13px',
color: theme.colors.grey,
}));
export default Legend;

View File

@@ -1,11 +0,0 @@
import styled from '@emotion/styled';
const ModalBody = styled.div({
width: '100%',
display: 'flex',
flex: 1,
alignItems: 'center',
flexDirection: 'column',
});
export default ModalBody;

View File

@@ -1,24 +0,0 @@
import styled from '@emotion/styled';
import TransitionLayout from '~/components/layouts/_transition_layout';
const ModalContainer = styled(TransitionLayout)(({ theme }) => ({
minWidth: '500px',
background: theme.colors.background,
padding: '1em',
borderRadius: theme.border.radius,
marginTop: '6em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
boxShadow: theme.colors.boxShadow,
[`@media (max-width: ${theme.media.mobile})`]: {
maxHeight: 'calc(100% - 2em)',
width: 'calc(100% - 2em)',
minWidth: 'unset',
marginTop: '1em',
},
}));
export default ModalContainer;

View File

@@ -1,20 +0,0 @@
import styled from '@emotion/styled';
const ModalHeader = styled.h3({
width: '100%',
marginBottom: '0.75em',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
});
const ModalCloseBtn = styled.button(({ theme }) => ({
cursor: 'pointer',
color: theme.colors.primary,
backgroundColor: 'transparent',
border: 0,
padding: 0,
margin: 0,
}));
export { ModalHeader, ModalCloseBtn };

View File

@@ -1,19 +0,0 @@
import styled from '@emotion/styled';
import { rgba } from '~/lib/color';
const ModalWrapper = styled.div(({ theme }) => ({
zIndex: 9999,
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: '100%',
background: rgba(theme.colors.black, 0.35),
backdropFilter: 'blur(0.1em)',
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
transition: theme.transition.delay,
}));
export default ModalWrapper;

View File

@@ -1,67 +0,0 @@
import { Fragment, ReactNode, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { IoClose } from 'react-icons/io5';
import ModalBody from '~/components/common/modal/_modal_body';
import ModalContainer from '~/components/common/modal/_modal_container';
import {
ModalCloseBtn,
ModalHeader,
} from '~/components/common/modal/_modal_header';
import ModalWrapper from '~/components/common/modal/_modal_wrapper';
import TextEllipsis from '~/components/common/text_ellipsis';
import useClickOutside from '~/hooks/use_click_outside';
import useShortcut from '~/hooks/use_shortcut';
import { useGlobalHotkeysStore } from '~/store/global_hotkeys_store';
interface ModalProps {
title?: string;
children: ReactNode;
opened: boolean;
hideCloseBtn?: boolean;
className?: string;
close: () => void;
}
export default function Modal({
title,
children,
opened = true,
hideCloseBtn = false,
className,
close,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const { setGlobalHotkeysEnabled } = useGlobalHotkeysStore();
useClickOutside(modalRef, close);
useShortcut('ESCAPE_KEY', close, { disableGlobalCheck: true });
useEffect(() => setGlobalHotkeysEnabled(!opened), [opened]);
if (typeof window === 'undefined') {
return <Fragment />;
}
return (
opened &&
createPortal(
<ModalWrapper>
<ModalContainer className={className} ref={modalRef}>
{(!hideCloseBtn || title) && (
<ModalHeader>
{title && <TextEllipsis>{title}</TextEllipsis>}
{!hideCloseBtn && (
<ModalCloseBtn onClick={close}>
<IoClose size={20} />
</ModalCloseBtn>
)}
</ModalHeader>
)}
<ModalBody>{children}</ModalBody>
</ModalContainer>
</ModalWrapper>,
document.body
)
);
}

View File

@@ -1,15 +0,0 @@
import styled from '@emotion/styled';
import { rgba } from '~/lib/color';
const RoundedImage = styled.img(({ theme }) => {
const transparentBlack = rgba(theme.colors.black, 0.1);
return {
borderRadius: '50%',
'&:hover': {
boxShadow: `0 1px 3px 0 ${transparentBlack}, 0 1px 2px -1px ${transparentBlack}`,
},
};
});
export default RoundedImage;

View File

@@ -1,227 +0,0 @@
import styled from '@emotion/styled';
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
SortingState,
useReactTable,
} from '@tanstack/react-table';
import { useState } from 'react';
import IconButton from '~/components/common/icon_button';
import {
MdKeyboardArrowLeft,
MdKeyboardArrowRight,
MdKeyboardDoubleArrowLeft,
MdKeyboardDoubleArrowRight,
} from 'react-icons/md';
import Input from '~/components/common/form/_input';
const TablePageFooter = styled.div({
display: 'flex',
gap: '1em',
alignItems: 'center',
});
const Box = styled(TablePageFooter)({
gap: '0.35em',
});
const Resizer = styled.div<{ isResizing: boolean }>(
({ theme, isResizing }) => ({
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none',
position: 'absolute',
right: 0,
top: 0,
height: '100%',
width: '5px',
opacity: !isResizing ? 0 : 1,
background: !isResizing ? theme.colors.white : theme.colors.primary,
'&:hover': {
opacity: 0.5,
},
})
);
type TableProps<T> = {
columns: ColumnDef<T>[];
data: T[];
defaultSorting?: SortingState;
};
export default function Table<T>({
columns,
data,
defaultSorting = [],
}: TableProps<T>) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [sorting, setSorting] = useState<SortingState>(defaultSorting);
const table = useReactTable({
data,
columns,
enableColumnResizing: true,
columnResizeMode: 'onChange',
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: setSorting,
debugTable: true,
});
return (
<div css={{ fontSize: '0.9rem', paddingBlock: '1em' }}>
<div
css={{
maxWidth: '100%',
marginBottom: '1em',
display: 'block',
overflowX: 'auto',
overflowY: 'hidden',
}}
>
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={{
width: header.getSize(),
}}
css={{
position: 'relative',
userSelect: 'none',
// resizer
'&:hover > div > div': {
opacity: 0.5,
},
}}
colSpan={header.colSpan}
>
{header.isPlaceholder ? null : (
<div
css={{
cursor: header.column.getCanSort()
? 'pointer'
: 'default',
}}
onClick={header.column.getToggleSortingHandler()}
title={
header.column.getCanSort()
? header.column.getNextSortingOrder() === 'asc'
? 'Sort ascending'
: header.column.getNextSortingOrder() === 'desc'
? 'Sort descending'
: 'Clear sort'
: undefined
}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
{header.column.getCanResize() && (
<Resizer
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
isResizing={header.column.getIsResizing()}
/>
)}
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table
.getRowModel()
.rows.slice(0, 10)
.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} style={{ width: cell.column.getSize() }}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{table.getPageCount() > 1 && (
<TablePageFooter>
<Box>
<IconButton
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<MdKeyboardDoubleArrowLeft />
</IconButton>
<IconButton
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<MdKeyboardArrowLeft />
</IconButton>
<IconButton
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<MdKeyboardArrowRight />
</IconButton>
<IconButton
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<MdKeyboardDoubleArrowRight />
</IconButton>
</Box>
<Box>
<span>Page</span>
<strong>
{table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</strong>
</Box>
<Box>
Go to page
<Input
type="number"
min="1"
max={table.getPageCount()}
defaultValue={table.getState().pagination.pageIndex + 1}
onChange={(e) => {
const page = e.target.value ? Number(e.target.value) - 1 : 0;
table.setPageIndex(page);
}}
/>
</Box>
</TablePageFooter>
)}
</div>
);
}

View File

@@ -1,24 +0,0 @@
import styled from '@emotion/styled';
import { rgba } from '~/lib/color';
const TabItem = styled.li<{ active?: boolean; danger?: boolean }>(
({ theme, active, danger }) => {
const activeColor = !danger ? theme.colors.primary : theme.colors.lightRed;
return {
userSelect: 'none',
cursor: 'pointer',
backgroundColor: active
? rgba(activeColor, 0.15)
: theme.colors.secondary,
padding: '10px 20px',
border: `1px solid ${active ? rgba(activeColor, 0.1) : theme.colors.secondary}`,
borderBottom: `1px solid ${active ? rgba(activeColor, 0.25) : theme.colors.secondary}`,
display: 'flex',
gap: '0.35em',
alignItems: 'center',
transition: '.075s',
};
}
);
export default TabItem;

View File

@@ -1,10 +0,0 @@
import styled from '@emotion/styled';
const TabList = styled.ul({
padding: 0,
margin: 0,
display: 'flex',
listStyle: 'none',
});
export default TabList;

View File

@@ -1,12 +0,0 @@
import styled from '@emotion/styled';
import { rgba } from '~/lib/color';
const TabPanel = styled.div(({ theme }) => ({
zIndex: 1,
position: 'relative',
border: `1px solid ${rgba(theme.colors.primary, 0.25)}`,
padding: '20px',
marginTop: '-1px',
}));
export default TabPanel;

View File

@@ -1,42 +0,0 @@
import { ReactNode, useState } from 'react';
import { IconType } from 'react-icons/lib';
import TabItem from '~/components/common/tabs/tab_item';
import TabList from '~/components/common/tabs/tab_list';
import TabPanel from '~/components/common/tabs/tab_panel';
import TransitionLayout from '~/components/layouts/_transition_layout';
export interface Tab {
title: string;
content: ReactNode;
icon?: IconType;
danger?: boolean;
}
export default function Tabs({ tabs }: { tabs: Tab[] }) {
const [activeTabIndex, setActiveTabIndex] = useState<number>(0);
const handleTabClick = (index: number) => {
setActiveTabIndex(index);
};
return (
<div css={{ width: '100%' }}>
<TabList>
{tabs.map(({ title, icon: Icon, danger }, index) => (
<TabItem
key={index}
active={index === activeTabIndex}
onClick={() => handleTabClick(index)}
danger={danger ?? false}
>
{!!Icon && <Icon size={20} />}
{title}
</TabItem>
))}
</TabList>
<TabPanel key={tabs[activeTabIndex].title}>
<TransitionLayout>{tabs[activeTabIndex].content}</TransitionLayout>
</TabPanel>
</div>
);
}

View File

@@ -1,20 +0,0 @@
import styled from '@emotion/styled';
const TextEllipsis = styled.p<{ lines?: number }>(({ lines = 1 }) => {
if (lines > 1) {
return {
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: lines,
WebkitBoxOrient: 'vertical',
};
}
return {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
};
});
export default TextEllipsis;

View File

@@ -1,12 +0,0 @@
import styled from '@emotion/styled';
const UnstyledList = styled.ul({
'&, & li': {
listStyleType: 'none',
margin: 0,
padding: 0,
border: 0,
},
});
export default UnstyledList;

View File

@@ -1,39 +0,0 @@
import styled from '@emotion/styled';
import { ReactNode } from 'react';
import CollectionHeader from '~/components/dashboard/collection/header/collection_header';
import LinkList from '~/components/dashboard/link/link_list';
import { NoCollection } from '~/components/dashboard/link/no_item';
import Footer from '~/components/footer/footer';
import { useActiveCollection } from '~/store/collection_store';
export interface CollectionHeaderProps {
showButtons: boolean;
showControls?: boolean;
openNavigationItem?: ReactNode;
openCollectionItem?: ReactNode;
}
const CollectionContainerStyle = styled.div({
height: '100%',
minWidth: 0,
padding: '0.5em 0.5em 0',
display: 'flex',
flex: 1,
flexDirection: 'column',
});
export default function CollectionContainer(props: CollectionHeaderProps) {
const { activeCollection } = useActiveCollection();
if (activeCollection === null) {
return <NoCollection />;
}
return (
<CollectionContainerStyle>
<CollectionHeader {...props} />
<LinkList links={activeCollection.links} />
<Footer css={{ paddingBottom: 0 }} />
</CollectionContainerStyle>
);
}

View File

@@ -1,42 +0,0 @@
import { route } from '@izzyjs/route/client';
import { useTranslation } from 'react-i18next';
import { BsThreeDotsVertical } from 'react-icons/bs';
import { GoPencil } from 'react-icons/go';
import { IoIosAddCircleOutline } from 'react-icons/io';
import { IoTrashOutline } from 'react-icons/io5';
import Dropdown from '~/components/common/dropdown/dropdown';
import { DropdownItemLink } from '~/components/common/dropdown/dropdown_item';
import { appendCollectionId } from '~/lib/navigation';
import { Collection } from '~/types/app';
export default function CollectionControls({
collectionId,
}: {
collectionId: Collection['id'];
}) {
const { t } = useTranslation('common');
return (
<Dropdown label={<BsThreeDotsVertical />} svgSize={18}>
<DropdownItemLink href={route('link.create-form').url}>
<IoIosAddCircleOutline /> {t('link.create')}
</DropdownItemLink>
<DropdownItemLink
href={appendCollectionId(
route('collection.edit-form').url,
collectionId
)}
>
<GoPencil /> {t('collection.edit')}
</DropdownItemLink>
<DropdownItemLink
href={appendCollectionId(
route('collection.delete-form').url,
collectionId
)}
danger
>
<IoTrashOutline /> {t('collection.delete')}
</DropdownItemLink>
</Dropdown>
);
}

View File

@@ -1,18 +0,0 @@
import styled from '@emotion/styled';
import TextEllipsis from '~/components/common/text_ellipsis';
import { useActiveCollection } from '~/store/collection_store';
const CollectionDescriptionStyle = styled.div({
width: '100%',
fontSize: '0.85rem',
marginBottom: '0.5rem',
});
export default function CollectionDescription() {
const { activeCollection } = useActiveCollection();
return (
<CollectionDescriptionStyle>
<TextEllipsis lines={3}>{activeCollection!.description}</TextEllipsis>
</CollectionDescriptionStyle>
);
}

View File

@@ -1,87 +0,0 @@
import styled from '@emotion/styled';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import TextEllipsis from '~/components/common/text_ellipsis';
import { CollectionHeaderProps } from '~/components/dashboard/collection/collection_container';
import CollectionControls from '~/components/dashboard/collection/header/collection_controls';
import CollectionDescription from '~/components/dashboard/collection/header/collection_description';
import VisibilityBadge from '~/components/visibilty/visibilty';
import { useActiveCollection } from '~/store/collection_store';
const paddingLeft = '1.25em';
const paddingRight = '1.65em';
const CollectionHeaderWrapper = styled.div(({ theme }) => ({
minWidth: 0,
width: '100%',
paddingInline: `${paddingLeft} ${paddingRight}`,
marginBottom: '0.5em',
[`@media (max-width: ${theme.media.tablet})`]: {
paddingInline: 0,
marginBottom: '1rem',
},
}));
const CollectionHeaderStyle = styled.div({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
'& > svg': {
display: 'flex',
},
});
const CollectionName = styled.h2(({ theme }) => ({
width: `calc(100% - (${paddingLeft} + ${paddingRight}))`,
color: theme.colors.primary,
display: 'flex',
gap: '0.35em',
alignItems: 'center',
[`@media (max-width: ${theme.media.tablet})`]: {
width: `calc(100% - (${paddingLeft} + ${paddingRight} + 1.75em))`,
},
}));
const LinksCount = styled.div(({ theme }) => ({
minWidth: 'fit-content',
fontWeight: 300,
fontSize: '0.8em',
color: theme.colors.grey,
}));
export default function CollectionHeader({
showButtons,
showControls = true,
openNavigationItem,
openCollectionItem,
}: CollectionHeaderProps) {
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
if (!activeCollection) return <Fragment />;
const { name, links, visibility } = activeCollection;
return (
<CollectionHeaderWrapper>
<CollectionHeaderStyle>
{showButtons && openNavigationItem && openNavigationItem}
<CollectionName title={name}>
<TextEllipsis>{name}</TextEllipsis>
{links.length > 0 && <LinksCount> {links.length}</LinksCount>}
<VisibilityBadge
label={t('collection.visibility')}
visibility={visibility}
/>
</CollectionName>
{showControls && (
<CollectionControls collectionId={activeCollection.id} />
)}
{showButtons && openCollectionItem && openCollectionItem}
</CollectionHeaderStyle>
{activeCollection.description && <CollectionDescription />}
</CollectionHeaderWrapper>
);
}

View File

@@ -1,57 +0,0 @@
import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { useEffect, useRef } from 'react';
import { AiFillFolderOpen, AiOutlineFolder } from 'react-icons/ai';
import TextEllipsis from '~/components/common/text_ellipsis';
import { Item } from '~/components/dashboard/side_nav/nav_item';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
import { CollectionWithLinks } from '~/types/app';
const CollectionItemStyle = styled(Item, {
shouldForwardProp: (propName) => propName !== 'isActive',
})<{ isActive: boolean }>(({ theme, isActive }) => ({
cursor: 'pointer',
color: isActive ? theme.colors.primary : theme.colors.font,
backgroundColor: theme.colors.secondary,
}));
const CollectionItemLink = CollectionItemStyle.withComponent(Link);
const LinksCount = styled.div(({ theme }) => ({
minWidth: 'fit-content',
fontWeight: 300,
fontSize: '0.9rem',
color: theme.colors.grey,
}));
export default function CollectionItem({
collection,
}: {
collection: CollectionWithLinks;
}) {
const itemRef = useRef<HTMLDivElement>(null);
const { activeCollection } = useActiveCollection();
const isActiveCollection = collection.id === activeCollection?.id;
const FolderIcon = isActiveCollection ? AiFillFolderOpen : AiOutlineFolder;
useEffect(() => {
if (collection.id === activeCollection?.id) {
itemRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [collection.id, activeCollection?.id]);
return (
<CollectionItemLink
href={appendCollectionId(route('dashboard').url, collection.id)}
isActive={isActiveCollection}
ref={itemRef}
>
<FolderIcon css={{ minWidth: '24px' }} size={24} />
<TextEllipsis>{collection.name}</TextEllipsis>
{collection.links.length > 0 && (
<LinksCount> {collection.links.length}</LinksCount>
)}
</CollectionItemLink>
);
}

View File

@@ -1,37 +1,9 @@
import styled from '@emotion/styled';
import { Box, ScrollArea, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import CollectionItem from '~/components/dashboard/collection/list/collection_item';
import CollectionListContainer from '~/components/dashboard/collection/list/collection_list_container';
import CollectionItem from '~/components/dashboard/collection/item/collection_item';
import useShortcut from '~/hooks/use_shortcut';
import { useActiveCollection, useCollections } from '~/store/collection_store';
const SideMenu = styled.nav(({ theme }) => ({
height: '100%',
width: '300px',
backgroundColor: theme.colors.background,
paddingLeft: '10px',
marginLeft: '5px',
borderLeft: `1px solid ${theme.colors.lightGrey}`,
display: 'flex',
gap: '.35em',
flexDirection: 'column',
}));
const CollectionLabel = styled.p(({ theme }) => ({
color: theme.colors.grey,
marginBlock: '0.35em',
paddingInline: '15px',
}));
const CollectionListStyle = styled.div({
padding: '1px',
paddingRight: '5px',
display: 'flex',
flex: 1,
gap: '.35em',
flexDirection: 'column',
overflow: 'auto',
});
import styles from './collection_list.module.css';
export default function CollectionList() {
const { t } = useTranslation('common');
@@ -64,18 +36,20 @@ export default function CollectionList() {
useShortcut('ARROW_DOWN', goToNextCollection);
return (
<SideMenu>
<CollectionListContainer>
<CollectionLabel>
{t('collection.collections', { count: collections.length })} {' '}
{collections.length}
</CollectionLabel>
<CollectionListStyle>
{collections.map((collection) => (
<CollectionItem collection={collection} key={collection.id} />
))}
</CollectionListStyle>
</CollectionListContainer>
</SideMenu>
<Box className={styles.sideMenu}>
<Box className={styles.listContainer}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Text c="dimmed" ml="md" mb="sm">
{t('collection.collections', { count: collections.length })} {' '}
{collections.length}
</Text>
<ScrollArea className={styles.collectionList}>
{collections.map((collection) => (
<CollectionItem collection={collection} key={collection.id} />
))}
</ScrollArea>
</div>
</Box>
</Box>
);
}

View File

@@ -1,11 +0,0 @@
import styled from '@emotion/styled';
const CollectionListContainer = styled.div({
height: '100%',
minHeight: 0,
paddingInline: '10px',
display: 'flex',
flexDirection: 'column',
});
export default CollectionListContainer;

View File

@@ -1,5 +1,5 @@
import { AppShell, Burger, Group, ScrollArea, Text } from '@mantine/core';
import CollectionList from '~/mantine/components/dashboard/collection/list/collection_list';
import CollectionList from '~/components/dashboard/collection/list/collection_list';
interface DashboardAsideProps {
isOpen: boolean;

View File

@@ -15,9 +15,9 @@ import { AiOutlineFolderAdd } from 'react-icons/ai';
import { IoIosSearch } from 'react-icons/io';
import { IoAdd, IoShieldHalfSharp } from 'react-icons/io5';
import { PiGearLight } from 'react-icons/pi';
import { MantineUserCard } from '~/components/common/mantine_user_card';
import { MantineUserCard } from '~/components/common/user_card';
import { FavoriteList } from '~/components/dashboard/favorite/favorite_list';
import useUser from '~/hooks/use_user';
import { FavoriteList } from '~/mantine/components/dashboard/favorite/favorite_list';
interface DashboardNavbarProps {
isOpen: boolean;

View File

@@ -1,95 +0,0 @@
import { router } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { ReactNode, useMemo, useState } from 'react';
import { ActiveCollectionContext } from '~/contexts/active_collection_context';
import CollectionsContext from '~/contexts/collections_context';
import FavoritesContext from '~/contexts/favorites_context';
import GlobalHotkeysContext from '~/contexts/global_hotkeys_context';
import useShortcut from '~/hooks/use_shortcut';
import { appendCollectionId } from '~/lib/navigation';
import { CollectionWithLinks, LinkWithCollection } from '~/types/app';
export default function DashboardProviders(
props: Readonly<{
children: ReactNode;
collections: CollectionWithLinks[];
activeCollection: CollectionWithLinks;
}>
) {
const [globalHotkeysEnabled, setGlobalHotkeysEnabled] =
useState<boolean>(true);
const [collections, setCollections] = useState<CollectionWithLinks[]>(
props.collections
);
const [activeCollection, setActiveCollection] =
useState<CollectionWithLinks | null>(
props.activeCollection || collections?.[0]
);
const handleChangeCollection = (collection: CollectionWithLinks) => {
setActiveCollection(collection);
router.visit(appendCollectionId(route('dashboard').url, collection.id));
};
// TODO: compute this in controller
const favorites = useMemo<LinkWithCollection[]>(
() =>
collections.reduce((acc, collection) => {
collection.links.forEach((link) => {
if (link.favorite) {
const newLink: LinkWithCollection = { ...link, collection };
acc.push(newLink);
}
});
return acc;
}, [] as LinkWithCollection[]),
[collections]
);
const collectionsContextValue = useMemo(
() => ({ collections, setCollections }),
[collections]
);
const activeCollectionContextValue = useMemo(
() => ({ activeCollection, setActiveCollection: handleChangeCollection }),
[activeCollection, handleChangeCollection]
);
const favoritesContextValue = useMemo(() => ({ favorites }), [favorites]);
const globalHotkeysContextValue = useMemo(
() => ({
globalHotkeysEnabled,
setGlobalHotkeysEnabled,
}),
[globalHotkeysEnabled]
);
useShortcut(
'OPEN_CREATE_LINK_KEY',
() =>
router.visit(
appendCollectionId(route('link.create-form').url, activeCollection?.id)
),
{
enabled: globalHotkeysEnabled,
}
);
useShortcut(
'OPEN_CREATE_COLLECTION_KEY',
() => router.visit(route('collection.create-form').url),
{
enabled: globalHotkeysEnabled,
}
);
return (
<CollectionsContext.Provider value={collectionsContextValue}>
<ActiveCollectionContext.Provider value={activeCollectionContextValue}>
<FavoritesContext.Provider value={favoritesContextValue}>
<GlobalHotkeysContext.Provider value={globalHotkeysContextValue}>
{props.children}
</GlobalHotkeysContext.Provider>
</FavoritesContext.Provider>
</ActiveCollectionContext.Provider>
</CollectionsContext.Provider>
);
}

View File

@@ -1,6 +1,6 @@
import { Box, Group, ScrollArea, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { FavoriteItem } from '~/mantine/components/dashboard/favorite/item/favorite_item';
import { FavoriteItem } from '~/components/dashboard/favorite/item/favorite_item';
import { useFavorites } from '~/store/collection_store';
import styles from './favorite_list.module.css';

View File

@@ -1,38 +0,0 @@
import styled from '@emotion/styled';
import LinkItem from '~/components/dashboard/link/link_item';
import { NoLink } from '~/components/dashboard/link/no_item';
import { sortByCreationDate } from '~/lib/array';
import { Link } from '~/types/app';
const LinkListStyle = styled.ul({
height: '100%',
width: '100%',
minWidth: 0,
display: 'flex',
flex: 1,
gap: '0.5em',
padding: '3px',
flexDirection: 'column',
overflowX: 'hidden',
overflowY: 'scroll',
});
export default function LinkList({
links,
showControls = true,
}: {
links: Link[];
showControls?: boolean;
}) {
if (links.length === 0) {
return <NoLink />;
}
return (
<LinkListStyle>
{sortByCreationDate(links).map((link) => (
<LinkItem link={link} key={link.id} showUserControls={showControls} />
))}
</LinkListStyle>
);
}

View File

@@ -1,66 +0,0 @@
import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { useTranslation } from 'react-i18next';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
import { fadeIn } from '~/styles/keyframes';
const NoCollectionStyle = styled.div({
minWidth: 0,
display: 'flex',
flex: 1,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
animation: `${fadeIn} 0.3s both`,
});
const Text = styled.p({
minWidth: 0,
width: '100%',
textAlign: 'center',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
export function NoCollection() {
const { t } = useTranslation('home');
return (
<NoCollectionStyle>
<Text>{t('select-collection')}</Text>
<Link href={route('collection.create-form').url}>
{t('or-create-one')}
</Link>
</NoCollectionStyle>
);
}
export function NoLink() {
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
return (
<NoCollectionStyle>
<Text
dangerouslySetInnerHTML={{
__html: t(
'home:no-link',
{ name: activeCollection?.name ?? '' } as any,
{
interpolation: { escapeValue: false },
}
),
}}
/>
<Link
href={appendCollectionId(
route('link.create-form').url,
activeCollection?.id
)}
>
{t('link.create')}
</Link>
</NoCollectionStyle>
);
}

View File

@@ -1,29 +0,0 @@
import styled from '@emotion/styled';
import { useTranslation } from 'react-i18next';
import { FcGoogle } from 'react-icons/fc';
const NoSearchResultStyle = styled.i({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.25em',
'& > span': {
display: 'flex',
alignItems: 'center',
},
});
export default function NoSearchResult() {
const { t } = useTranslation('common');
return (
<NoSearchResultStyle>
{t('search-with')}
{''}
<span>
<FcGoogle size={20} />
oogle
</span>
</NoSearchResultStyle>
);
}

View File

@@ -1,155 +0,0 @@
import styled from '@emotion/styled';
import { route } from '@izzyjs/route/client';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IoIosSearch } from 'react-icons/io';
import Modal from '~/components/common/modal/modal';
import NoSearchResult from '~/components/dashboard/search/no_search_result';
import SearchResultList from '~/components/dashboard/search/search_result_list';
import { GOOGLE_SEARCH_URL } from '~/constants';
import useToggle from '~/hooks/use_modal';
import useShortcut from '~/hooks/use_shortcut';
import { makeRequest } from '~/lib/request';
import { useActiveCollection, useCollections } from '~/store/collection_store';
import { SearchResult } from '~/types/search';
const SearchInput = styled.input(({ theme }) => ({
width: '100%',
fontSize: '20px',
color: theme.colors.font,
backgroundColor: 'transparent',
paddingLeft: 0,
border: '1px solid transparent',
}));
interface SearchModalProps {
openItem: any;
}
function SearchModal({ openItem: OpenItem }: SearchModalProps) {
const { t } = useTranslation();
const { collections } = useCollections();
const { setActiveCollection } = useActiveCollection();
const [searchTerm, setSearchTerm] = useState<string>('');
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedItem, setSelectedItem] = useState<SearchResult | null>(null);
const searchModal = useToggle(!!searchTerm);
const handleCloseModal = useCallback(() => {
searchModal.close();
setSearchTerm('');
}, [searchModal]);
const handleSearchInputChange = (value: string) => setSearchTerm(value);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
handleCloseModal();
if (results.length === 0) {
return window.open(GOOGLE_SEARCH_URL + encodeURI(searchTerm.trim()));
}
if (!selectedItem) return;
if (selectedItem.type === 'collection') {
const collection = collections.find((c) => c.id === selectedItem.id);
if (collection) {
setActiveCollection(collection);
}
return;
}
window.open(selectedItem.url);
};
useShortcut('OPEN_SEARCH_KEY', searchModal.open, {
enabled: !searchModal.isShowing,
});
useShortcut('ESCAPE_KEY', handleCloseModal, {
enabled: searchModal.isShowing,
disableGlobalCheck: true,
});
useEffect(() => {
if (searchTerm.trim() === '') {
return setResults([]);
}
const controller = new AbortController();
const { url, method } = route('search', { qs: { term: searchTerm } });
makeRequest({
method,
url,
controller,
}).then(({ results: _results }) => {
setResults(_results);
setSelectedItem(_results?.[0]);
});
return () => controller.abort();
}, [searchTerm]);
return (
<>
<OpenItem onClick={searchModal.open}>
<IoIosSearch /> {t('common:search')}
</OpenItem>
<Modal
close={handleCloseModal}
opened={searchModal.isShowing}
hideCloseBtn
css={{ width: '650px' }}
>
<form
onSubmit={handleSubmit}
css={{
width: '100%',
display: 'flex',
gap: '0.5em',
flexDirection: 'column',
}}
>
<div
css={{
display: 'flex',
gap: '0.35em',
alignItems: 'center',
justifyContent: 'center',
}}
>
<label htmlFor="search" css={{ display: 'flex' }}>
<IoIosSearch size={24} />
</label>
<SearchInput
name="search"
id="search"
onChange={({ target }) => handleSearchInputChange(target.value)}
value={searchTerm}
placeholder={t('common:search')}
autoFocus
/>
</div>
{results.length > 0 && selectedItem && (
<SearchResultList
results={results}
selectedItem={selectedItem}
setSelectedItem={setSelectedItem}
/>
)}
{results.length === 0 && !!searchTerm.trim() && <NoSearchResult />}
<button
type="submit"
disabled={searchTerm.length === 0}
style={{ display: 'none' }}
>
{t('common:confirm')}
</button>
</form>
</Modal>
</>
);
}
export default SearchModal;

View File

@@ -1,123 +0,0 @@
import styled from '@emotion/styled';
import { RefObject, useEffect, useRef, useState } from 'react';
import { AiOutlineFolder } from 'react-icons/ai';
import Legend from '~/components/common/legend';
import TextEllipsis from '~/components/common/text_ellipsis';
import LinkFavicon from '~/components/dashboard/link/link_favicon';
import { useCollections } from '~/store/collection_store';
import {
SearchResult,
SearchResultCollection,
SearchResultLink,
} from '~/types/search';
const SearchItemStyle = styled('li', {
shouldForwardProp: (propName) => propName !== 'isActive',
})<{ isActive: boolean }>(({ theme, isActive }) => ({
fontSize: '16px',
backgroundColor: isActive ? theme.colors.secondary : 'transparent',
display: 'flex',
gap: '0.35em',
alignItems: 'center',
borderRadius: theme.border.radius,
padding: '0.25em 0.35em !important',
}));
interface CommonResultProps {
innerRef: RefObject<HTMLLIElement>;
isActive: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export default function SearchResultItem({
result,
isActive,
onHover,
}: {
result: SearchResult;
isActive: boolean;
onHover: (result: SearchResult) => void;
}) {
const itemRef = useRef<HTMLLIElement>(null);
const [isHovering, setHovering] = useState<boolean>(false);
const onMouseEnter = () => {
if (!isHovering) {
onHover(result);
setHovering(true);
}
};
const onMouseLeave = () => setHovering(false);
useEffect(() => {
if (isActive && !isHovering) {
itemRef.current?.scrollIntoView({
behavior: 'instant',
block: 'nearest',
});
}
}, [itemRef, isActive]);
const commonProps = {
onMouseEnter,
onMouseLeave,
isActive,
};
return result.type === 'collection' ? (
<ResultCollection result={result} innerRef={itemRef} {...commonProps} />
) : (
<ResultLink result={result} innerRef={itemRef} {...commonProps} />
);
}
function ResultLink({
result,
innerRef,
...props
}: {
result: SearchResultLink;
} & CommonResultProps) {
const { collections } = useCollections();
const collection = collections.find((c) => c.id === result.collection_id);
const link = collection?.links.find((l) => l.id === result.id);
if (!collection || !link) return <></>;
return (
<SearchItemStyle
key={result.type + result.id.toString()}
ref={innerRef}
{...props}
>
<LinkFavicon url={link.url} size={20} />
<TextEllipsis
dangerouslySetInnerHTML={{
__html: result.matched_part ?? result.name,
}}
/>
<Legend>({collection.name})</Legend>
</SearchItemStyle>
);
}
const ResultCollection = ({
result,
innerRef,
...props
}: {
result: SearchResultCollection;
} & CommonResultProps) => (
<SearchItemStyle
key={result.type + result.id.toString()}
ref={innerRef}
{...props}
>
<AiOutlineFolder size={24} />
<TextEllipsis
dangerouslySetInnerHTML={{
__html: result.matched_part ?? result.name,
}}
/>
</SearchItemStyle>
);

View File

@@ -1,52 +0,0 @@
import UnstyledList from '~/components/common/unstyled/unstyled_list';
import SearchResultItem from '~/components/dashboard/search/search_result_item';
import useShortcut from '~/hooks/use_shortcut';
import { SearchResult } from '~/types/search';
export default function SearchResultList({
results,
selectedItem,
setSelectedItem,
}: {
results: SearchResult[];
selectedItem: SearchResult;
setSelectedItem: (result: SearchResult) => void;
}) {
const selectedItemIndex = results.findIndex(
(item) => item.id === selectedItem.id && item.type === selectedItem.type
);
useShortcut(
'ARROW_UP',
() => setSelectedItem(results[selectedItemIndex - 1]),
{
enabled: results.length > 1 && selectedItemIndex !== 0,
disableGlobalCheck: true,
}
);
useShortcut(
'ARROW_DOWN',
() => setSelectedItem(results[selectedItemIndex + 1]),
{
enabled: results.length > 1 && selectedItemIndex !== results.length - 1,
disableGlobalCheck: true,
}
);
return (
<UnstyledList css={{ maxHeight: '500px', overflow: 'auto' }}>
{results.map((result) => (
<SearchResultItem
result={result}
onHover={setSelectedItem}
isActive={
selectedItem &&
selectedItem.id === result.id &&
selectedItem.type === result.type
}
key={result.type + result.id.toString()}
/>
))}
</UnstyledList>
);
}

View File

@@ -1,11 +0,0 @@
import styled from '@emotion/styled';
const FavoriteListContainer = styled.div({
height: '100%',
minHeight: 0,
paddingInline: '10px',
display: 'flex',
flexDirection: 'column',
});
export default FavoriteListContainer;

View File

@@ -1,33 +0,0 @@
import styled from '@emotion/styled';
import { useTranslation } from 'react-i18next';
import { AiFillStar, AiOutlineStar } from 'react-icons/ai';
import { DropdownItemButton } from '~/components/common/dropdown/dropdown_item';
import { onFavorite } from '~/lib/favorite';
import { useFavorites } from '~/store/collection_store';
import { Link } from '~/types/app';
const StarItem = styled(DropdownItemButton)(({ theme }) => ({
color: theme.colors.yellow,
}));
export default function FavoriteDropdownItem({ link }: { link: Link }) {
const { toggleFavorite } = useFavorites();
const { t } = useTranslation();
const onFavoriteCallback = () => toggleFavorite(link.id);
return (
<StarItem
onClick={() => onFavorite(link.id, !link.favorite, onFavoriteCallback)}
>
{!link.favorite ? (
<>
<AiFillStar /> {t('add-favorite')}
</>
) : (
<>
<AiOutlineStar /> {t('remove-favorite')}
</>
)}
</StarItem>
);
}

View File

@@ -1,69 +0,0 @@
import styled from '@emotion/styled';
import { route } from '@izzyjs/route/client';
import { useTranslation } from 'react-i18next';
import { BsThreeDotsVertical } from 'react-icons/bs';
import { FaRegEye } from 'react-icons/fa';
import { GoPencil } from 'react-icons/go';
import { IoTrashOutline } from 'react-icons/io5';
import Dropdown from '~/components/common/dropdown/dropdown';
import { DropdownItemLink } from '~/components/common/dropdown/dropdown_item';
import Legend from '~/components/common/legend';
import TextEllipsis from '~/components/common/text_ellipsis';
import LinkFavicon from '~/components/dashboard/link/link_favicon';
import FavoriteDropdownItem from '~/components/dashboard/side_nav/favorite/favorite_dropdown_item';
import { ItemExternalLink } from '~/components/dashboard/side_nav/nav_item';
import { appendCollectionId, appendLinkId } from '~/lib/navigation';
import { LinkWithCollection } from '~/types/app';
const FavoriteItemStyle = styled(ItemExternalLink)(({ theme }) => ({
height: 'auto',
backgroundColor: theme.colors.secondary,
}));
const FavoriteDropdown = styled(Dropdown)(({ theme }) => ({
backgroundColor: theme.colors.secondary,
}));
const FavoriteContainer = styled.div({
flex: 1,
lineHeight: '1.1rem',
});
export default function FavoriteItem({ link }: { link: LinkWithCollection }) {
const { t } = useTranslation();
return (
<FavoriteItemStyle href={link.url}>
<LinkFavicon url={link.url} size={24} />
<FavoriteContainer>
<TextEllipsis>{link.name}</TextEllipsis>
<Legend>{link.collection.name}</Legend>
</FavoriteContainer>
<FavoriteDropdown
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
label={<BsThreeDotsVertical />}
svgSize={18}
>
<DropdownItemLink
href={appendCollectionId(route('dashboard').url, link.collection.id)}
>
<FaRegEye /> {t('go-to-collection')}
</DropdownItemLink>
<FavoriteDropdownItem link={link} />
<DropdownItemLink
href={appendLinkId(route('link.edit-form').url, link.id)}
>
<GoPencil /> {t('link.edit')}
</DropdownItemLink>
<DropdownItemLink
href={appendLinkId(route('link.delete-form').url, link.id)}
danger
>
<IoTrashOutline /> {t('link.delete')}
</DropdownItemLink>
</FavoriteDropdown>
</FavoriteItemStyle>
);
}

View File

@@ -1,51 +0,0 @@
import styled from '@emotion/styled';
import { useTranslation } from 'react-i18next';
import FavoriteListContainer from '~/components/dashboard/side_nav/favorite/favorite_container';
import FavoriteItem from '~/components/dashboard/side_nav/favorite/favorite_item';
import { useFavorites } from '~/store/collection_store';
const FavoriteLabel = styled.p(({ theme }) => ({
color: theme.colors.grey,
marginBlock: '0.35em',
paddingInline: '15px',
}));
const NoFavorite = () => {
const { t } = useTranslation('common');
return (
<FavoriteLabel css={{ textAlign: 'center' }}>
{t('favorites-appears-here')}
</FavoriteLabel>
);
};
const FavoriteListStyle = styled.div({
padding: '1px',
paddingRight: '5px',
display: 'flex',
flex: 1,
gap: '.35em',
flexDirection: 'column',
overflow: 'auto',
});
export default function FavoriteList() {
const { t } = useTranslation('common');
const { favorites } = useFavorites();
if (favorites.length === 0) {
return <NoFavorite key="no-favorite" />;
}
return (
<FavoriteListContainer>
<FavoriteLabel>
{t('favorite')} {favorites.length}
</FavoriteLabel>
<FavoriteListStyle>
{favorites.map((link) => (
<FavoriteItem link={link} key={link.id} />
))}
</FavoriteListStyle>
</FavoriteListContainer>
);
}

View File

@@ -1,31 +0,0 @@
import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
import ExternalLink from '~/components/common/external_link';
import { rgba } from '~/lib/color';
export const Item = styled.div(({ theme }) => ({
userSelect: 'none',
height: '40px',
width: '100%',
color: theme.colors.font,
backgroundColor: theme.colors.background,
padding: '8px 12px',
borderRadius: theme.border.radius,
display: 'flex',
gap: '.75em',
alignItems: 'center',
'& > svg': {
height: '24px',
width: '24px',
},
// Disable hover effect for UserCard
'&:hover:not(.disable-hover)': {
cursor: 'pointer',
backgroundColor: rgba(theme.colors.font, 0.1),
},
}));
export const ItemLink = Item.withComponent(Link);
export const ItemExternalLink = Item.withComponent(ExternalLink);

View File

@@ -1,80 +0,0 @@
import styled from '@emotion/styled';
import { route } from '@izzyjs/route/client';
import { useTranslation } from 'react-i18next';
import { AiOutlineFolderAdd } from 'react-icons/ai';
import { IoAdd, IoShieldHalfSharp } from 'react-icons/io5';
import SearchModal from '~/components/dashboard/search/search_modal';
import FavoriteList from '~/components/dashboard/side_nav/favorite/favorite_list';
import { Item, ItemLink } from '~/components/dashboard/side_nav/nav_item';
import UserCard from '~/components/dashboard/side_nav/user_card';
import ModalSettings from '~/components/settings/settings_modal';
import useUser from '~/hooks/use_user';
import { rgba } from '~/lib/color';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/store/collection_store';
const SideMenu = styled.nav(({ theme }) => ({
height: '100%',
width: '300px',
backgroundColor: theme.colors.background,
borderRight: `1px solid ${theme.colors.lightGrey}`,
marginRight: '5px',
display: 'flex',
gap: '.35em',
flexDirection: 'column',
}));
const AdminButton = styled(ItemLink)(({ theme }) => ({
color: theme.colors.lightRed,
'&:hover': {
backgroundColor: `${rgba(theme.colors.lightRed, 0.1)}!important`,
},
}));
const SettingsButton = styled(Item)(({ theme }) => ({
color: theme.colors.grey,
'&:hover': {
backgroundColor: `${rgba(theme.colors.grey, 0.1)}!important`,
},
}));
const AddButton = styled(ItemLink)(({ theme }) => ({
color: theme.colors.primary,
'&:hover': {
backgroundColor: `${rgba(theme.colors.primary, 0.1)}!important`,
},
}));
const SearchButton = AddButton.withComponent(Item);
export default function SideNavigation() {
const { user } = useUser();
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
return (
<SideMenu>
<div css={{ paddingInline: '10px' }}>
<UserCard />
{user!.isAdmin && (
<AdminButton href={route('admin.dashboard').url}>
<IoShieldHalfSharp /> {t('admin')}
</AdminButton>
)}
<ModalSettings openItem={SettingsButton} />
<SearchModal openItem={SearchButton} />
<AddButton
href={appendCollectionId(
route('link.create-form').url,
activeCollection?.id
)}
>
<IoAdd /> {t('link.create')}
</AddButton>
<AddButton href={route('collection.create-form').url}>
<AiOutlineFolderAdd /> {t('collection.create')}
</AddButton>
</div>
<FavoriteList />
</SideMenu>
);
}

View File

@@ -1,21 +0,0 @@
import RoundedImage from '~/components/common/rounded_image';
import { Item } from '~/components/dashboard/side_nav/nav_item';
import useUser from '~/hooks/use_user';
export default function UserCard() {
const { user, isAuthenticated } = useUser();
const altImage = `${user?.fullname}'s avatar`;
return (
isAuthenticated && (
<Item className="disable-hover">
<RoundedImage
src={user.avatarUrl}
width={24}
alt={altImage}
referrerPolicy="no-referrer"
/>
{user.fullname}
</Item>
)
);
}

View File

@@ -1,10 +0,0 @@
import styled from '@emotion/styled';
const SwiperHandler = styled.div(({ theme }) => ({
height: '100%',
width: '100%',
display: 'flex',
transition: `background-color ${theme.transition.delay}`,
}));
export default SwiperHandler;

View File

@@ -1,44 +1,58 @@
import PATHS from '#constants/paths';
import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { Anchor, Group, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import ExternalLink from '~/components/common/external_link';
import { MantineLanguageSwitcher } from '~/components/common/language_switcher';
import { MantineThemeSwitcher } from '~/components/common/theme_switcher';
import packageJson from '../../../package.json';
import classes from './footer.module.css';
const FooterStyle = styled.footer(({ theme }) => ({
fontSize: '0.9em',
color: theme.colors.grey,
textAlign: 'center',
paddingBlock: '0.75em',
'& a:hover': {
textDecoration: 'underline',
},
}));
export default function Footer({ className }: { className?: string }) {
export function MantineFooter() {
const { t } = useTranslation('common');
const links = [
{ link: route('privacy').url, label: t('privacy') },
{ link: route('terms').url, label: t('terms') },
{ link: PATHS.EXTENSION, label: 'Extension' },
];
const items = links.map((link) => (
<Anchor
c="dimmed"
component={Link}
key={link.label}
href={link.link}
size="sm"
>
{link.label}
</Anchor>
));
return (
<FooterStyle className={className}>
<div className="row">
<Link href={route('privacy').url}>{t('privacy')}</Link>
{' '}
<Link href={route('terms').url}>{t('terms')}</Link>
{' • '}
<ExternalLink href={PATHS.EXTENSION}>Extension</ExternalLink>
</div>
<div className="row">
{t('footer.made_by')}{' '}
<ExternalLink href={PATHS.AUTHOR}>Sonny</ExternalLink>
{' • '}
<span>
Version:{' '}
<ExternalLink href={PATHS.REPO_GITHUB}>
<div className={classes.footer}>
<div className={classes.inner}>
<Group gap={4} c="dimmed">
<Text size="sm">{t('footer.made_by')}</Text>{' '}
<Anchor size="sm" component={ExternalLink} href={PATHS.AUTHOR}>
Sonny
</Anchor>
{' • '}
<Anchor size="sm" component={ExternalLink} href={PATHS.REPO_GITHUB}>
{packageJson.version}
</ExternalLink>
</span>
</Anchor>
</Group>
<Group gap="sm" mt={4} mb={4}>
<MantineThemeSwitcher />
<MantineLanguageSwitcher />
</Group>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{items}
</Group>
</div>
</FooterStyle>
</div>
);
}

View File

@@ -1,58 +0,0 @@
import PATHS from '#constants/paths';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { Anchor, Group, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import ExternalLink from '~/components/common/external_link';
import { MantineLanguageSwitcher } from '~/components/common/mantine_language_switcher';
import { MantineThemeSwitcher } from '~/components/common/mantine_theme_switcher';
import packageJson from '../../../package.json';
import classes from './footer.module.css';
export function MantineFooter() {
const { t } = useTranslation('common');
const links = [
{ link: route('privacy').url, label: t('privacy') },
{ link: route('terms').url, label: t('terms') },
{ link: PATHS.EXTENSION, label: 'Extension' },
];
const items = links.map((link) => (
<Anchor
c="dimmed"
component={Link}
key={link.label}
href={link.link}
size="sm"
>
{link.label}
</Anchor>
));
return (
<div className={classes.footer}>
<div className={classes.inner}>
<Group gap={4} c="dimmed">
<Text size="sm">{t('footer.made_by')}</Text>{' '}
<Anchor size="sm" component={ExternalLink} href={PATHS.AUTHOR}>
Sonny
</Anchor>
{' • '}
<Anchor size="sm" component={ExternalLink} href={PATHS.REPO_GITHUB}>
{packageJson.version}
</Anchor>
</Group>
<Group gap="sm" mt={4} mb={4}>
<MantineThemeSwitcher />
<MantineLanguageSwitcher />
</Group>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{items}
</Group>
</div>
</div>
);
}

View File

@@ -1,10 +1,9 @@
import { Visibility } from '#enums/visibility';
import { Box, Group, SegmentedControl, Text, TextInput } from '@mantine/core';
import { FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import Checkbox from '~/components/common/form/checkbox';
import TextBox from '~/components/common/form/textbox';
import BackToDashboard from '~/components/common/navigation/back_to_dashboard';
import FormLayout from '~/components/layouts/form_layout';
import { FormLayout, FormLayoutProps } from '~/layouts/form_layout';
import { Collection } from '~/types/app';
export type FormCollectionData = {
@@ -14,34 +13,24 @@ export type FormCollectionData = {
nextId?: Collection['id'];
};
interface FormCollectionProps {
title: string;
canSubmit: boolean;
disableHomeLink?: boolean;
interface FormCollectionProps extends FormLayoutProps {
data: FormCollectionData;
errors?: Record<string, Array<string>>;
disableInputs?: boolean;
submitBtnDanger?: boolean;
setData: (name: string, value: any) => void;
handleSubmit: () => void;
}
export default function FormCollection({
title,
canSubmit,
disableHomeLink,
export default function MantineFormCollection({
data,
errors,
disableInputs = false,
submitBtnDanger = false,
setData,
handleSubmit,
...props
}: FormCollectionProps) {
const { t } = useTranslation('common');
const handleOnCheck: FormCollectionProps['setData'] = (name, value) =>
setData(name, value ? Visibility.PUBLIC : Visibility.PRIVATE);
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -49,41 +38,50 @@ export default function FormCollection({
};
return (
<FormLayout
title={title}
handleSubmit={onSubmit}
canSubmit={canSubmit}
disableHomeLink={disableHomeLink}
submitBtnDanger={submitBtnDanger}
>
<BackToDashboard>
<TextBox
label={t('collection.name')}
placeholder={t('collection.name')}
name="name"
onChange={setData}
<FormLayout handleSubmit={onSubmit} {...props}>
<BackToDashboard disabled={props.disableHomeLink}>
<TextInput
label={t('form.name')}
placeholder={t('form.name')}
onChange={({ target }) => setData('name', target.value)}
value={data.name}
errors={errors?.name}
required
readOnly={disableInputs}
error={errors?.name}
mt="md"
autoFocus
disabled={disableInputs}
required
/>
<TextBox
label={t('collection.description')}
placeholder={t('collection.description')}
name="description"
onChange={setData}
<TextInput
label={t('form.description')}
placeholder={t('form.description')}
onChange={({ target }) => setData('description', target.value)}
value={data.description ?? undefined}
errors={errors?.description}
disabled={disableInputs}
/>
<Checkbox
label="Public"
name="visibility"
onChange={handleOnCheck}
checked={data.visibility === Visibility.PUBLIC}
disabled={disableInputs}
readOnly={disableInputs}
error={errors?.description}
mt="md"
/>
<Box mt="md">
<Text size="sm" fw={500} mb={3}>
{t('form.visibility')}
</Text>
<Group wrap="nowrap">
<SegmentedControl
data={[
{ label: t('visibility.private'), value: Visibility.PRIVATE },
{ label: t('visibility.public'), value: Visibility.PUBLIC },
]}
onChange={(value) => setData('visibility', value as Visibility)}
value={data.visibility}
style={{ minWidth: 'fit-content' }}
readOnly={disableInputs}
/>
{data.visibility === Visibility.PUBLIC && (
<Text c="dimmed" size="sm">
{t('form.visibility-warning')}
</Text>
)}
</Group>
</Box>
</BackToDashboard>
</FormLayout>
);

View File

@@ -1,11 +1,9 @@
import { Checkbox, Select, TextInput } from '@mantine/core';
import { FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import Checkbox from '~/components/common/form/checkbox';
import Selector from '~/components/common/form/selector';
import TextBox from '~/components/common/form/textbox';
import BackToDashboard from '~/components/common/navigation/back_to_dashboard';
import FormLayout from '~/components/layouts/form_layout';
import useSearchParam from '~/hooks/use_search_param';
import { FormLayout, FormLayoutProps } from '~/layouts/form_layout';
import { Collection } from '~/types/app';
export type FormLinkData = {
@@ -16,32 +14,24 @@ export type FormLinkData = {
collectionId: Collection['id'];
};
interface FormLinkProps {
title: string;
canSubmit: boolean;
disableHomeLink?: boolean;
interface FormLinkProps extends FormLayoutProps {
data: FormLinkData;
errors?: Record<string, Array<string>>;
collections: Collection[];
disableInputs?: boolean;
submitBtnDanger?: boolean;
setData: (name: string, value: any) => void;
handleSubmit: () => void;
}
export default function FormLink({
title,
canSubmit,
disableHomeLink,
export default function MantineFormLink({
data,
errors,
collections,
disableInputs = false,
submitBtnDanger = false,
setData,
handleSubmit,
...props
}: FormLinkProps) {
const { t } = useTranslation('common');
const collectionId =
@@ -53,64 +43,59 @@ export default function FormLink({
};
return (
<FormLayout
title={title}
handleSubmit={onSubmit}
canSubmit={canSubmit}
disableHomeLink={disableHomeLink}
collectionId={collectionId}
submitBtnDanger={submitBtnDanger}
>
<BackToDashboard>
<TextBox
label={t('link.name')}
placeholder={t('link.name')}
name="name"
onChange={setData}
<FormLayout handleSubmit={onSubmit} collectionId={collectionId} {...props}>
<BackToDashboard disabled={props.disableHomeLink}>
<TextInput
label={t('form.name')}
placeholder={t('form.name')}
onChange={({ target }) => setData('name', target.value)}
value={data.name}
errors={errors?.name}
disabled={disableInputs}
required
readOnly={disableInputs}
error={errors?.name}
mt="md"
autoFocus
/>
<TextBox
label={t('link.link')}
placeholder={t('link.link')}
name="url"
onChange={setData}
value={data.url}
errors={errors?.url}
disabled={disableInputs}
required
/>
<TextBox
label={t('link.description')}
placeholder={t('link.description')}
name="description"
onChange={setData}
value={data.description ?? undefined}
errors={errors?.description}
disabled={disableInputs}
<TextInput
label={t('form.url')}
placeholder={t('form.url')}
onChange={({ target }) => setData('url', target.value)}
value={data.url ?? undefined}
readOnly={disableInputs}
error={errors?.link}
mt="md"
required
/>
<Selector
<TextInput
label={t('form.description')}
placeholder={t('form.description')}
onChange={({ target }) => setData('description', target.value)}
value={data.description ?? undefined}
readOnly={disableInputs}
error={errors?.description}
mt="md"
/>
<Select
label={t('collection.collections')}
name="collection"
placeholder={t('collection.collections')}
value={data.collectionId}
onChangeCallback={(value) => setData('collectionId', value)}
options={collections.map(({ id, name }) => ({
data={collections.map(({ id, name }) => ({
label: name,
value: id,
value: id.toString(),
}))}
onChange={(value) => setData('collectionId', value)}
value={data.collectionId.toString()}
readOnly={disableInputs}
mt="md"
searchable
required
/>
<Checkbox
label={t('favorite')}
name="favorite"
onChange={setData}
onChange={({ target }) => setData('favorite', target.checked)}
checked={data.favorite}
errors={errors?.favorite}
disabled={disableInputs}
error={errors?.favorite}
disabled={disableInputs} // readonly not working
mt="md"
/>
</BackToDashboard>
</FormLayout>

View File

@@ -4,7 +4,7 @@ import { AiFillFolderOpen } from 'react-icons/ai';
import { FaUser } from 'react-icons/fa';
import { IoIosLink, IoIosSearch, IoIosShareAlt } from 'react-icons/io';
import { IoExtensionPuzzleOutline } from 'react-icons/io5';
import { featureList } from '~/mantine/components/home/feature_list';
import { featureList } from '~/components/home/feature_list';
type FeatureName = (typeof featureList)[number];

View File

@@ -1,5 +1,5 @@
import { SimpleGrid } from '@mantine/core';
import { Feature } from '~/mantine/components/home/feature';
import { Feature } from '~/components/home/feature';
export const featureList = [
'collection',

View File

@@ -1,53 +0,0 @@
import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
import Selector from '~/components/common/form/selector';
import { LS_LANG_KEY } from '~/constants';
import { languages } from '~/i18n';
type Country = 'fr' | 'en';
export default function LangSelector({
onSelected,
}: {
onSelected?: (country: Country) => void;
}) {
const { t, i18n } = useTranslation('common');
const onToggleLanguageClick = (newLocale: string) => {
dayjs.locale(newLocale);
i18n.changeLanguage(newLocale);
localStorage.setItem(LS_LANG_KEY, newLocale);
};
return (
<Selector
name="lng-select"
label={t('select-your-lang')}
value={i18n.language}
onChangeCallback={(value) => {
onToggleLanguageClick(value.toString());
if (onSelected) {
setTimeout(() => onSelected(value.toString() as Country), 150);
}
}}
options={languages.map((lang: Country) => ({
label: t(`language.${lang}`),
value: lang,
}))}
formatOptionLabel={(country) => (
<div
className="country-option"
style={{ display: 'flex', gap: '.5em', alignItems: 'center' }}
>
<img
src={`/icons/${country.value}.svg`}
alt="country-image"
height={24}
width={24}
/>
<span>{country.label}</span>
</div>
)}
/>
);
}

View File

@@ -1,15 +0,0 @@
import dayjs from 'dayjs';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import ContextThemeProvider from '~/components/layouts/_theme_layout';
import DarkThemeContextProvider from '~/contexts/dark_theme_context';
export default function BaseLayout({ children }: { children: ReactNode }) {
const { i18n } = useTranslation();
dayjs.locale(i18n.language);
return (
<DarkThemeContextProvider>
<ContextThemeProvider>{children}</ContextThemeProvider>
</DarkThemeContextProvider>
);
}

View File

@@ -1,147 +0,0 @@
import { Global, ThemeProvider, css, useTheme } from '@emotion/react';
import { ReactNode } from 'react';
import useDarkTheme from '~/hooks/use_dark_theme';
import { darkTheme } from '~/styles/themes/dark_theme';
import { lightTheme } from '~/styles/themes/light_theme';
export default function ContextThemeProvider({
children,
}: {
children: ReactNode;
}) {
const { isDarkTheme } = useDarkTheme();
return (
<ThemeProvider theme={isDarkTheme ? darkTheme : lightTheme}>
<GlobalStyles />
{children}
</ThemeProvider>
);
}
function GlobalStyles() {
const localTheme = useTheme();
const cssReset = css({
'*': {
boxSizing: 'border-box',
outline: 0,
margin: 0,
padding: 0,
scrollBehavior: 'smooth',
},
'.reset': {
backgroundColor: 'inherit',
color: 'inherit',
padding: 0,
margin: 0,
border: 0,
},
a: {
width: 'fit-content',
color: localTheme.colors.primary,
textDecoration: 'none',
borderBottom: '1px solid transparent',
},
b: {
fontWeight: 600,
letterSpacing: '0.5px',
},
'h1, h2, h3, h4, h5, h6': {
fontWeight: '500',
color: localTheme.colors.primary,
},
kbd: {
textShadow: '0 1px 0 #fff',
fontSize: '12px',
color: 'rgb(51, 51, 51)',
backgroundColor: 'rgb(247, 247, 247)',
padding: '0.25em 0.5em',
borderRadius: '3px',
border: '1px solid rgb(204, 204, 204)',
boxShadow: '0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px #ffffff inset',
display: 'inline-block',
},
hr: {
width: '100%',
marginBlock: '1em',
border: 0,
borderTop: `1px solid ${localTheme.colors.background}`,
},
});
const documentStyle = css({
'html, body, #app': {
height: '100svh',
width: '100%',
fontFamily: "'Poppins', sans-serif",
fontSize: '14px',
color: localTheme.colors.font,
backgroundColor: localTheme.colors.background,
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
},
});
const scrollbarStyle = css({
/* width */
'::-webkit-scrollbar': {
height: '0.45em',
width: '0.45em',
},
/* Track */
'::-webkit-scrollbar-track': {
borderRadius: localTheme.border.radius,
},
/* Handle */
'::-webkit-scrollbar-thumb': {
background: localTheme.colors.primary,
borderRadius: localTheme.border.radius,
'&:hover': {
background: localTheme.colors.darkBlue,
},
},
});
const tableStyle = css({
table: {
height: 'auto',
width: '100%',
borderCollapse: 'collapse',
borderRadius: localTheme.border.radius,
overflow: 'hidden',
},
th: {
textAlign: 'center',
fontWeight: 400,
backgroundColor: localTheme.colors.secondary,
},
'td, th': {
padding: '0.45em',
},
'th, td': {
whiteSspace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
'tr:nth-of-type(even)': {
backgroundColor: localTheme.colors.secondary,
},
});
return (
<Global styles={[cssReset, documentStyle, scrollbarStyle, tableStyle]} />
);
}

View File

@@ -1,8 +0,0 @@
import styled from '@emotion/styled';
import { fadeInScale } from '~/styles/keyframes';
const TransitionLayout = styled.div(({ theme }) => ({
animation: `${theme.transition.delay} ${fadeInScale} both`,
}));
export default TransitionLayout;

View File

@@ -1,38 +0,0 @@
import styled from '@emotion/styled';
import { ReactNode } from 'react';
import Footer from '~/components/footer/footer';
import TransitionLayout from '~/components/layouts/_transition_layout';
import Navbar from '~/components/navbar/navbar';
import BaseLayout from './_base_layout';
const ContentLayoutStyle = styled(TransitionLayout)(({ theme }) => ({
height: '100%',
width: theme.media.small_desktop,
maxWidth: '100%',
padding: '1em',
display: 'flex',
flexDirection: 'column',
'& main': {
width: '100%',
flex: 1,
},
}));
const ContentLayout = ({
children,
className,
}: {
children: ReactNode;
className?: string;
}) => (
<BaseLayout>
<ContentLayoutStyle className={className}>
<Navbar />
<main>{children}</main>
<Footer />
</ContentLayoutStyle>
</BaseLayout>
);
export default ContentLayout;

View File

@@ -1,21 +0,0 @@
import styled from '@emotion/styled';
import { ReactNode } from 'react';
import TransitionLayout from '~/components/layouts/_transition_layout';
import BaseLayout from './_base_layout';
const DashboardLayoutStyle = styled(TransitionLayout)(({ theme }) => ({
position: 'relative',
height: '100%',
width: theme.media.medium_desktop,
maxWidth: '100%',
padding: '0.75em 1em',
overflow: 'hidden',
}));
const DashboardLayout = ({ children }: { children: ReactNode }) => (
<BaseLayout>
<DashboardLayoutStyle>{children}</DashboardLayoutStyle>
</BaseLayout>
);
export default DashboardLayout;

View File

@@ -1,69 +0,0 @@
import styled from '@emotion/styled';
import { Head, Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { FormEvent, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '~/components/common/form/_button';
import Form from '~/components/common/form/_form';
import TransitionLayout from '~/components/layouts/_transition_layout';
import i18n from '~/i18n';
import { appendCollectionId } from '~/lib/navigation';
import BaseLayout from './_base_layout';
const FormLayoutStyle = styled(TransitionLayout)(({ theme }) => ({
height: 'fit-content',
width: theme.media.mobile,
maxWidth: '100%',
marginTop: '10em',
paddingInline: '1em',
display: 'flex',
gap: '0.75em',
flexDirection: 'column',
}));
interface FormLayoutProps {
title: string;
children: ReactNode;
canSubmit: boolean;
handleSubmit: (event: FormEvent<HTMLFormElement>) => void;
textSubmitButton?: string;
disableHomeLink?: boolean;
submitBtnDanger?: boolean;
collectionId?: number;
}
export default function FormLayout({
title,
children,
canSubmit,
handleSubmit,
textSubmitButton = i18n.t('common:confirm'),
disableHomeLink = false,
submitBtnDanger = false,
collectionId,
}: FormLayoutProps) {
const { t } = useTranslation('common');
return (
<BaseLayout>
<FormLayoutStyle>
<Head title={title} />
<h2>{title}</h2>
<Form onSubmit={handleSubmit}>
{children}
<Button type="submit" disabled={!canSubmit} danger={submitBtnDanger}>
{textSubmitButton}
</Button>
</Form>
{!disableHomeLink && (
<Link href={appendCollectionId(route('dashboard').url, collectionId)}>
{t('back-home')}
</Link>
)}
</FormLayoutStyle>
</BaseLayout>
);
}

View File

@@ -1,36 +0,0 @@
import styled from '@emotion/styled';
import { ReactNode } from 'react';
import ContentLayout from '~/components/layouts/content_layout';
import LegalFooter from '~/components/legal/legal_footer';
const LegalContentStyle = styled(ContentLayout)({
main: {
'h1, p': {
marginTop: '0.5em',
},
h2: {
marginTop: '1.5em',
},
h3: {
marginTop: '1em',
},
ul: {
marginLeft: '2em',
},
},
});
const LegalContentLayout = ({
children,
className,
}: {
children: ReactNode;
className?: string;
}) => (
<LegalContentStyle className={className}>
{children}
<LegalFooter />
</LegalContentStyle>
);
export default LegalContentLayout;

View File

@@ -1,20 +0,0 @@
import { useTranslation } from 'react-i18next';
import ExternalLink from '~/components/common/external_link';
export default function LegalFooter() {
const { t } = useTranslation('legal');
return (
<>
<h2>{t('contact.title')}</h2>
<p>
{t('contact.description')}{' '}
<ExternalLink href="mailto:sonnyasdev@gmail.com" target="_blank">
sonnyasdev[at]gmail[dot]com
</ExternalLink>
</p>
<p>{t('footer.changes')}</p>
<p css={{ marginBottom: '2em' }}>{t('footer.thanks')}</p>
</>
);
}

View File

@@ -1,116 +0,0 @@
import PATHS from '#constants/paths';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import {
Box,
Burger,
Button,
Divider,
Drawer,
Group,
Image,
ScrollArea,
rem,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import ExternalLink from '~/components/common/external_link';
import { MantineLanguageSwitcher } from '~/components/common/mantine_language_switcher';
import { MantineThemeSwitcher } from '~/components/common/mantine_theme_switcher';
import { MantineUserCard } from '~/components/common/mantine_user_card';
import useUser from '~/hooks/use_user';
import classes from './mobile_navbar.module.css';
export default function MantineNavbar() {
const { t } = useTranslation('common');
const { isAuthenticated } = useUser();
const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] =
useDisclosure(false);
return (
<Box pb={40}>
<header className={classes.header}>
<Group justify="space-between" h="100%">
<Image src="/logo-light.png" h={35} alt="MyLinks's logo" />
<Group h="100%" gap={0} visibleFrom="sm">
<Link href="/" className={classes.link}>
{t('home')}
</Link>
<ExternalLink href={PATHS.REPO_GITHUB} className={classes.link}>
Github
</ExternalLink>
<ExternalLink href={PATHS.EXTENSION} className={classes.link}>
Extension
</ExternalLink>
</Group>
<Group gap="xs">
<MantineThemeSwitcher />
<MantineLanguageSwitcher />
{!isAuthenticated ? (
<Button
component="a"
href={route('auth').path}
visibleFrom="sm"
w={110}
>
{t('login')}
</Button>
) : (
<Button
component={Link}
href={route('dashboard').path}
visibleFrom="sm"
w={110}
>
Dashboard
</Button>
)}
<Burger
opened={drawerOpened}
onClick={toggleDrawer}
hiddenFrom="sm"
/>
</Group>
</Group>
</header>
<Drawer
opened={drawerOpened}
onClose={closeDrawer}
size="100%"
padding="md"
title="Navigation"
hiddenFrom="sm"
zIndex={1000000}
>
<ScrollArea h={`calc(100vh - ${rem(80)})`} mx="-md">
<Divider my="sm" />
<Link href="#" className={classes.link}>
{t('home')}
</Link>
<ExternalLink href={PATHS.REPO_GITHUB} className={classes.link}>
Github
</ExternalLink>
<ExternalLink href={PATHS.EXTENSION} className={classes.link}>
Extension
</ExternalLink>
<Divider my="sm" />
<Group justify="center" grow pb="xl" px="md">
{!isAuthenticated ? (
<Button component="a" href={route('auth').path}>
{t('login')}
</Button>
) : (
<MantineUserCard />
)}
</Group>
</ScrollArea>
</Drawer>
</Box>
);
}

View File

@@ -1,132 +1,116 @@
import PATHS from '#constants/paths';
import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { useTranslation } from 'react-i18next';
import { IoIosLogOut } from 'react-icons/io';
import Dropdown from '~/components/common/dropdown/dropdown';
import {
DropdownItemButton,
DropdownItemLink,
} from '~/components/common/dropdown/dropdown_item';
Box,
Burger,
Button,
Divider,
Drawer,
Group,
Image,
ScrollArea,
rem,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import ExternalLink from '~/components/common/external_link';
import RoundedImage from '~/components/common/rounded_image';
import UnstyledList from '~/components/common/unstyled/unstyled_list';
import ModalSettings from '~/components/settings/settings_modal';
import { MantineLanguageSwitcher } from '~/components/common/language_switcher';
import { MantineThemeSwitcher } from '~/components/common/theme_switcher';
import { MantineUserCard } from '~/components/common/user_card';
import useUser from '~/hooks/use_user';
type NavbarListDirection = {
right?: boolean;
};
const Nav = styled.nav({
width: '100%',
padding: '0.75em 0',
display: 'flex',
alignItems: 'center',
});
const NavList = styled(UnstyledList)<NavbarListDirection>(
({ theme, right }) => ({
display: 'flex',
flex: 1,
gap: '1.5em',
justifyContent: right ? 'flex-end' : 'flex-start',
transition: theme.transition.delay,
'& li': {
display: 'flex',
alignItems: 'center',
},
})
);
const AdminLink = styled(Link)(({ theme }) => ({
color: theme.colors.lightRed,
}));
const UserCard = styled.div({
padding: '0.25em 0.5em',
display: 'flex',
gap: '0.35em',
alignItems: 'center',
justifyContent: 'center',
});
const DropdownItemButtonWithPadding = styled(DropdownItemButton)({
cursor: 'pointer',
padding: 0,
});
import classes from './mobile.module.css';
export default function Navbar() {
const { t } = useTranslation('common');
const { isAuthenticated, user } = useUser();
const { isAuthenticated } = useUser();
const [drawerOpened, { toggle: toggleDrawer, close: closeDrawer }] =
useDisclosure(false);
return (
<Nav>
<NavList>
<li>
<Link href={route('home').url} css={{ fontSize: '24px' }}>
MyLinks
</Link>
</li>
</NavList>
<NavList right>
<li>
<ExternalLink href={PATHS.REPO_GITHUB}>GitHub</ExternalLink>
</li>
{isAuthenticated && !!user ? (
<>
{user.isAdmin && (
<li>
<AdminLink href={route('admin.dashboard').url}>
{t('admin')}
</AdminLink>
</li>
<Box pb={40}>
<header className={classes.header}>
<Group justify="space-between" h="100%">
<Image src="/logo-light.png" h={35} alt="MyLinks's logo" />
<Group h="100%" gap={0} visibleFrom="sm">
<Link href="/" className={classes.link}>
{t('home')}
</Link>
<ExternalLink href={PATHS.REPO_GITHUB} className={classes.link}>
Github
</ExternalLink>
<ExternalLink href={PATHS.EXTENSION} className={classes.link}>
Extension
</ExternalLink>
</Group>
<Group gap="xs">
<MantineThemeSwitcher />
<MantineLanguageSwitcher />
{!isAuthenticated ? (
<Button
component="a"
href={route('auth').path}
visibleFrom="sm"
w={110}
>
{t('login')}
</Button>
) : (
<Button
component={Link}
href={route('dashboard').path}
visibleFrom="sm"
w={110}
>
Dashboard
</Button>
)}
<li>
<Link href={route('dashboard').url}>Dashboard</Link>
</li>
<li>
<ProfileDropdown />
</li>
</>
) : (
<>
<li>
<ModalSettings openItem={DropdownItemButtonWithPadding} />
</li>
<li>
<Link href={route('auth').path}>{t('login')}</Link>
</li>
</>
)}
</NavList>
</Nav>
);
}
function ProfileDropdown() {
const { t } = useTranslation('common');
const { user } = useUser();
return (
<Dropdown
label={
<UserCard>
<RoundedImage
src={user!.avatarUrl}
width={22}
referrerPolicy="no-referrer"
/>
{user!.fullname}
</UserCard>
}
>
<ModalSettings openItem={DropdownItemButton} />
<DropdownItemLink href={route('auth.logout').url} danger>
<IoIosLogOut /> {t('logout')}
</DropdownItemLink>
</Dropdown>
<Burger
opened={drawerOpened}
onClick={toggleDrawer}
hiddenFrom="sm"
/>
</Group>
</Group>
</header>
<Drawer
opened={drawerOpened}
onClose={closeDrawer}
size="100%"
padding="md"
title="Navigation"
hiddenFrom="sm"
zIndex={1000000}
>
<ScrollArea h={`calc(100vh - ${rem(80)})`} mx="-md">
<Divider my="sm" />
<Link href="#" className={classes.link}>
{t('home')}
</Link>
<ExternalLink href={PATHS.REPO_GITHUB} className={classes.link}>
Github
</ExternalLink>
<ExternalLink href={PATHS.EXTENSION} className={classes.link}>
Extension
</ExternalLink>
<Divider my="sm" />
<Group justify="center" grow pb="xl" px="md">
{!isAuthenticated ? (
<Button component="a" href={route('auth').path}>
{t('login')}
</Button>
) : (
<MantineUserCard />
)}
</Group>
</ScrollArea>
</Drawer>
</Box>
);
}

View File

@@ -1,28 +0,0 @@
import styled from '@emotion/styled';
import { rgba } from '~/lib/color';
const Quotes = styled.p(({ theme }) => ({
position: 'relative',
width: 'fit-content',
whiteSpace: 'pre-wrap',
textAlign: 'center',
fontStyle: 'italic',
color: rgba(theme.colors.font, 0.75),
'&::before, &::after': {
position: 'absolute',
fontFamily: 'sans-serif',
fontSize: '2.25em',
},
'&::before': {
left: '-0.65em',
content: '"“"',
},
'&::after': {
right: '-0.5em',
content: '"”"',
},
}));
export default Quotes;

View File

@@ -1,66 +0,0 @@
import styled from '@emotion/styled';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import dayjs from 'dayjs';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import RoundedImage from '~/components/common/rounded_image';
import UnstyledList from '~/components/common/unstyled/unstyled_list';
import { DATE_FORMAT } from '~/constants';
import useUser from '~/hooks/use_user';
const ProfileStyle = styled(UnstyledList)({
display: 'flex',
gap: '1.25em',
});
const Column = styled.div({
display: 'flex',
gap: '1rem',
flexDirection: 'column',
});
const Field = styled.li({
display: 'flex',
flexDirection: 'column',
});
export default function Profile() {
const { user, isAuthenticated } = useUser();
const { t } = useTranslation('common');
const avatarLabel = t('avatar', {
name: user?.name,
});
if (!isAuthenticated) {
return <Fragment />;
}
return (
<ProfileStyle>
<Column>
<RoundedImage
src={user.avatarUrl}
width={96}
height={96}
alt={avatarLabel}
title={avatarLabel}
/>
<Link href={route('auth.logout').url}>{t('logout')}</Link>
</Column>
<Column>
<Field>
<b>{t('name')}</b> {user.fullname}
</Field>
<Field>
<b>{t('email')}</b> {user.email}
</Field>
<Field>
<b>{t('member-since')}</b>{' '}
{dayjs(user.createdAt.toString()).format(DATE_FORMAT)}
</Field>
</Column>
</ProfileStyle>
);
}

View File

@@ -1,66 +0,0 @@
import { useTranslation } from 'react-i18next';
import { CiDark } from 'react-icons/ci';
import { FaUser } from 'react-icons/fa';
import { IoLanguageOutline } from 'react-icons/io5';
import { PiGearLight } from 'react-icons/pi';
import FormField from '~/components/common/form/_form_field';
import Modal from '~/components/common/modal/modal';
import Tabs, { Tab } from '~/components/common/tabs/tabs';
import LangSelector from '~/components/lang_selector';
import Profile from '~/components/settings/profile';
import ThemeSwitcher from '~/components/theme_switcher';
import useToggle from '~/hooks/use_modal';
import useUser from '~/hooks/use_user';
export default function ModalSettings({
openItem: OpenItem,
}: {
// TODO: fix this :()
openItem: any;
}) {
const { t } = useTranslation('common');
const { isShowing, open, close } = useToggle();
const { isAuthenticated } = useUser();
let tabs: Tab[] = [
{
title: t('lang'),
content: <LangSelector />,
icon: IoLanguageOutline,
},
{
title: 'Theme',
content: (
<FormField css={{ flexDirection: 'row' }}>
<label>Dark theme?</label>
<ThemeSwitcher />
</FormField>
),
icon: CiDark,
},
];
if (isAuthenticated) {
tabs.push({
title: t('profile'),
content: <Profile />,
icon: FaUser,
});
}
return (
<>
<OpenItem onClick={open}>
<PiGearLight size={20} />
{t('settings')}
</OpenItem>
<Modal
title={t('settings')}
opened={isShowing}
close={close}
css={{ justifyContent: 'flex-start' }}
>
<Tabs tabs={tabs} />
</Modal>
</>
);
}

View File

@@ -1,24 +0,0 @@
import { Fragment } from 'react';
import Toggle from 'react-toggle';
import useDarkTheme from '~/hooks/use_dark_theme';
export default function ThemeSwitcher() {
const { isDarkTheme, toggleDarkTheme } = useDarkTheme();
if (typeof window === 'undefined') {
return <Fragment />;
}
return (
<Toggle
onChange={({ target }) => toggleDarkTheme(target.checked)}
checked={isDarkTheme}
name="theme-switcher"
id="theme-switcher"
icons={{
checked: '☀️',
unchecked: '🌑',
}}
/>
);
}

View File

@@ -1,31 +0,0 @@
import { Visibility } from '#enums/visibility';
import styled from '@emotion/styled';
import { IoEarthOutline } from 'react-icons/io5';
const VisibilityStyle = styled.span(({ theme }) => ({
userSelect: 'none',
fontWeight: 300,
fontSize: '0.6em',
color: theme.colors.primary,
border: `1px solid ${theme.colors.primary}`,
borderRadius: '50px',
padding: '0.15em 0.65em',
display: 'flex',
gap: '0.35em',
alignItems: 'center',
}));
const VisibilityBadge = ({
label,
visibility,
}: {
label: string;
visibility: Visibility;
}) =>
visibility === Visibility.PUBLIC && (
<VisibilityStyle>
{label} <IoEarthOutline size="1em" />
</VisibilityStyle>
);
export default VisibilityBadge;

View File

@@ -1,15 +0,0 @@
import { createContext } from 'react';
import { CollectionWithLinks } from '~/types/app';
type ActiveCollectionContextType = {
activeCollection: CollectionWithLinks | null;
setActiveCollection: (collection: CollectionWithLinks) => void;
};
const iActiveCollectionContextState: ActiveCollectionContextType = {
activeCollection: null,
setActiveCollection: (_: CollectionWithLinks) => {},
};
export const ActiveCollectionContext =
createContext<ActiveCollectionContextType>(iActiveCollectionContextState);

View File

@@ -1,20 +0,0 @@
import { createContext } from 'react';
import { CollectionWithLinks } from '~/types/app';
type CollectionsContextType = {
collections: CollectionWithLinks[];
setCollections: (
collections: CollectionWithLinks[]
) => void | CollectionWithLinks[];
};
const iCollectionsContextState: CollectionsContextType = {
collections: [] as CollectionWithLinks[],
setCollections: (_: CollectionWithLinks[]) => {},
};
const CollectionsContext = createContext<CollectionsContextType>(
iCollectionsContextState
);
export default CollectionsContext;

View File

@@ -1,46 +0,0 @@
import { usePage } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { ReactNode, createContext, useEffect, useState } from 'react';
import { makeRequest } from '~/lib/request';
const LS_KEY = 'dark_theme';
export const DarkThemeContext = createContext({
isDarkTheme: true,
toggleDarkTheme: (_value: boolean) => {},
});
export default function DarkThemeContextProvider({
children,
}: {
children: ReactNode;
}) {
const { preferDarkTheme } = usePage<{ preferDarkTheme: boolean }>().props;
const [isDarkTheme, setDarkTheme] = useState<boolean>(preferDarkTheme);
const toggleDarkTheme = (value: boolean) => {
setDarkTheme(value);
const { method, url } = route('user.theme');
makeRequest({
method,
url,
body: {
preferDarkTheme: value,
},
});
};
useEffect(() => {
localStorage.setItem(LS_KEY, String(isDarkTheme));
}, [isDarkTheme]);
return (
<DarkThemeContext.Provider
value={{
isDarkTheme,
toggleDarkTheme,
}}
>
{children}
</DarkThemeContext.Provider>
);
}

View File

@@ -1,16 +0,0 @@
import { createContext } from 'react';
import { LinkWithCollection } from '~/types/app';
type FavoritesContextType = {
favorites: LinkWithCollection[];
};
const iFavoritesContextState = {
favorites: [] as LinkWithCollection[],
};
const FavoritesContext = createContext<FavoritesContextType>(
iFavoritesContextState
);
export default FavoritesContext;

View File

@@ -1,17 +0,0 @@
import { createContext } from 'react';
type GlobalHotkeysContext = {
globalHotkeysEnabled: boolean;
setGlobalHotkeysEnabled: (value: boolean) => void;
};
const iGlobalHotkeysContextState = {
globalHotkeysEnabled: true,
setGlobalHotkeysEnabled: (_: boolean) => {},
};
const GlobalHotkeysContext = createContext<GlobalHotkeysContext>(
iGlobalHotkeysContextState
);
export default GlobalHotkeysContext;

View File

@@ -1,22 +0,0 @@
import { RefObject, useEffect } from 'react';
// Source : https://stackoverflow.com/a/63359693
/**
* This Hook can be used for detecting clicks outside the Opened Menu
*/
export default function useClickOutside(
ref: RefObject<HTMLElement>,
onClickOutside: () => void
) {
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref?.current && !ref.current?.contains(event.target as any)) {
onClickOutside();
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [ref, onClickOutside]);
}

View File

@@ -1,5 +0,0 @@
import { useContext } from 'react';
import { DarkThemeContext } from '~/contexts/dark_theme_context';
const useDarkTheme = () => useContext(DarkThemeContext);
export default useDarkTheme;

Some files were not shown because too many files have changed in this diff Show More