mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-16 14:40:43 +03:00
feat: support web layout setting and breadcrumbs (#5425)
* fix: some bugs * fix: performance * feat: support system dark mode and different language * feat: support breadcrumb * feat: support breadcrumb * feat: support new doucment title * feat: support new doucment title
This commit is contained in:
parent
cb44a885a1
commit
b8b7a10b33
@ -3,15 +3,44 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/appflowy.svg" />
|
||||
<meta name="viewport"
|
||||
content="width=device-width,height=device-height,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover"
|
||||
>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title>AppFlowy</title>
|
||||
</head>
|
||||
<body id="body">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||
const body = document.body;
|
||||
const isWin = userAgent.indexOf('win') > -1;
|
||||
const isMac = userAgent.indexOf('mac') > -1;
|
||||
const isLinux = userAgent.indexOf('linux') > -1;
|
||||
const isFirefox = userAgent.indexOf('firefox') > -1;
|
||||
const isChrome = userAgent.indexOf('chrome') > -1;
|
||||
const isSafari = userAgent.indexOf('safari') > -1;
|
||||
if (isWin) {
|
||||
body.setAttribute('data-os', 'windows');
|
||||
} else if (isMac) {
|
||||
body.setAttribute('data-os', 'mac');
|
||||
} else if (isLinux) {
|
||||
body.setAttribute('data-os', 'linux');
|
||||
} else {
|
||||
body.setAttribute('data-os', 'unknown');
|
||||
}
|
||||
|
||||
if (isFirefox) {
|
||||
body.setAttribute('data-browser', 'firefox');
|
||||
} else if (isChrome) {
|
||||
body.setAttribute('data-browser', 'chrome');
|
||||
} else if (isSafari) {
|
||||
body.setAttribute('data-browser', 'safari');
|
||||
} else {
|
||||
body.setAttribute('data-browser', 'unknown');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -12,7 +12,7 @@
|
||||
"lint": "pnpm run sync:i18n && tsc --noEmit --project tsconfig.web.json && eslint --ext .js,.ts,.tsx . --ignore-path .eslintignore.web",
|
||||
"start": "vite preview --port 3000",
|
||||
"tauri:dev": "tauri dev",
|
||||
"css:variables": "node style-dictionary/config.cjs",
|
||||
"css:variables": "node scripts/generateTailwindColors.cjs",
|
||||
"sync:i18n": "node scripts/i18n.cjs",
|
||||
"link:client-api": "rm -rf node_modules/.vite && node scripts/create-symlink.cjs",
|
||||
"analyze": "cross-env ANALYZE_MODE=true vite build",
|
||||
@ -38,6 +38,7 @@
|
||||
"@types/react-swipeable-views": "^0.13.4",
|
||||
"async-retry": "^1.3.3",
|
||||
"axios": "^1.6.8",
|
||||
"colorthief": "^2.4.0",
|
||||
"dayjs": "^1.11.9",
|
||||
"decimal.js": "^10.4.3",
|
||||
"emoji-mart": "^5.5.2",
|
||||
@ -63,6 +64,7 @@
|
||||
"react-big-calendar": "^1.8.5",
|
||||
"react-color": "^2.19.3",
|
||||
"react-custom-scrollbars": "^4.2.1",
|
||||
"react-custom-scrollbars-2": "^4.5.0",
|
||||
"react-datepicker": "^4.23.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
@ -88,7 +90,6 @@
|
||||
"unsplash-js": "^7.0.19",
|
||||
"utf8": "^3.0.0",
|
||||
"validator": "^13.11.0",
|
||||
"valtio": "^1.12.1",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"y-indexeddb": "9.0.12",
|
||||
"yjs": "^13.6.14"
|
||||
|
File diff suppressed because it is too large
Load Diff
61
frontend/appflowy_web_app/scripts/generateTailwindColors.cjs
Normal file
61
frontend/appflowy_web_app/scripts/generateTailwindColors.cjs
Normal file
@ -0,0 +1,61 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read CSS file
|
||||
const cssFilePath = path.join(__dirname, '../src/styles/variables/light.variables.css');
|
||||
const cssContent = fs.readFileSync(cssFilePath, 'utf-8');
|
||||
|
||||
// Extract color variables
|
||||
const shadowVariables = cssContent.match(/--shadow:\s.*;/g);
|
||||
const colorVariables = cssContent.match(/--[\w-]+:\s*#[0-9a-fA-F]{6}/g);
|
||||
|
||||
if (!colorVariables) {
|
||||
console.error('No color variables found in CSS file.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const shadows = shadowVariables.reduce((shadows, variable) => {
|
||||
const [name, value] = variable.split(':').map(str => str.trim());
|
||||
const formattedName = name.replace('--', '').replace(/-/g, '_');
|
||||
const key = 'md';
|
||||
|
||||
shadows[key] = `var(${name})`;
|
||||
return shadows;
|
||||
}, {});
|
||||
// Generate Tailwind CSS colors configuration
|
||||
// Replace -- with _ and - with _ in color variable names
|
||||
const tailwindColors = colorVariables.reduce((colors, variable) => {
|
||||
const [name, value] = variable.split(':').map(str => str.trim());
|
||||
const formattedName = name.replace('--', '').replace(/-/g, '_');
|
||||
const category = formattedName.split('_')[0];
|
||||
const key = formattedName.replace(`${category}_`, '');
|
||||
|
||||
if (!colors[category]) {
|
||||
colors[category] = {};
|
||||
}
|
||||
colors[category][key] = `var(${name})`;
|
||||
return colors;
|
||||
}, {});
|
||||
|
||||
const tailwindColorsFormatted = JSON.stringify(tailwindColors, null, 2)
|
||||
.replace(/_/g, '-');
|
||||
const header = `/**\n` + '* Do not edit directly\n' + `* Generated on ${new Date().toUTCString()}\n` + `* Generated from $pnpm css:variables \n` + `*/\n\n`;
|
||||
|
||||
// Write Tailwind CSS colors configuration to file
|
||||
const tailwindColorTemplate = `
|
||||
${header}
|
||||
module.exports = ${tailwindColorsFormatted};
|
||||
`;
|
||||
|
||||
const tailwindShadowTemplate = `
|
||||
${header}
|
||||
module.exports = ${JSON.stringify(shadows, null, 2).replace(/_/g, '-')};
|
||||
`;
|
||||
|
||||
const tailwindConfigFilePath = path.join(__dirname, '../tailwind/colors.cjs');
|
||||
fs.writeFileSync(tailwindConfigFilePath, tailwindColorTemplate, 'utf-8');
|
||||
|
||||
const tailwindShadowFilePath = path.join(__dirname, '../tailwind/box-shadow.cjs');
|
||||
fs.writeFileSync(tailwindShadowFilePath, tailwindShadowTemplate, 'utf-8');
|
||||
|
||||
console.log('Tailwind CSS colors configuration generated successfully.');
|
@ -138,15 +138,15 @@ export interface FolderMeta {
|
||||
current_workspace: string;
|
||||
}
|
||||
|
||||
export enum CoverType {
|
||||
export enum DocCoverType {
|
||||
Color = 'CoverType.color',
|
||||
Image = 'CoverType.file',
|
||||
Asset = 'CoverType.asset',
|
||||
}
|
||||
|
||||
export type PageCover = {
|
||||
export type DocCover = {
|
||||
image_type?: ImageType;
|
||||
cover_selection_type?: CoverType;
|
||||
cover_selection_type?: DocCoverType;
|
||||
cover_selection?: string;
|
||||
} | null;
|
||||
|
||||
@ -166,6 +166,7 @@ export enum YjsEditorKey {
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
|
||||
database_row = 'data',
|
||||
user_awareness = 'user_awareness',
|
||||
empty = 'empty',
|
||||
|
||||
// document
|
||||
blocks = 'blocks',
|
||||
@ -199,6 +200,10 @@ export enum YjsFolderKey {
|
||||
id = 'id',
|
||||
name = 'name',
|
||||
icon = 'icon',
|
||||
extra = 'extra',
|
||||
cover = 'cover',
|
||||
line_height_layout = 'line_height_layout',
|
||||
font_layout = 'font_layout',
|
||||
type = 'ty',
|
||||
value = 'value',
|
||||
layout = 'layout',
|
||||
@ -337,7 +342,7 @@ export interface YView extends Y.Map<unknown> {
|
||||
get(key: YjsFolderKey.name): string;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unified-signatures
|
||||
get(key: YjsFolderKey.icon): string;
|
||||
get(key: YjsFolderKey.icon | YjsFolderKey.extra): string;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/unified-signatures
|
||||
get(key: YjsFolderKey.layout): string;
|
||||
@ -607,3 +612,15 @@ export const databaseLayoutMap = {
|
||||
[DatabaseViewLayout.Board]: 'board',
|
||||
[DatabaseViewLayout.Calendar]: 'calendar',
|
||||
};
|
||||
|
||||
export enum FontLayout {
|
||||
small = 'small',
|
||||
normal = 'normal',
|
||||
large = 'large',
|
||||
}
|
||||
|
||||
export enum LineHeightLayout {
|
||||
small = 'small',
|
||||
normal = 'normal',
|
||||
large = 'large',
|
||||
}
|
||||
|
@ -5,9 +5,10 @@ import * as Y from 'yjs';
|
||||
|
||||
export interface DatabaseContextState {
|
||||
readOnly: boolean;
|
||||
doc: YDoc;
|
||||
databaseDoc: YDoc;
|
||||
viewId: string;
|
||||
rowDocMap: Y.Map<YDoc>;
|
||||
isDatabaseRowPage?: boolean;
|
||||
navigateToRow?: (rowId: string) => void;
|
||||
}
|
||||
|
||||
@ -15,18 +16,30 @@ export const DatabaseContext = createContext<DatabaseContextState | null>(null);
|
||||
|
||||
export const useDatabase = () => {
|
||||
const database = useContext(DatabaseContext)
|
||||
?.doc?.getMap(YjsEditorKey.data_section)
|
||||
?.databaseDoc?.getMap(YjsEditorKey.data_section)
|
||||
.get(YjsEditorKey.database) as YDatabase;
|
||||
|
||||
return database;
|
||||
};
|
||||
|
||||
export function useDatabaseViewId() {
|
||||
return useContext(DatabaseContext)?.viewId;
|
||||
}
|
||||
|
||||
export const useNavigateToRow = () => {
|
||||
return useContext(DatabaseContext)?.navigateToRow;
|
||||
};
|
||||
|
||||
export const useRowDocMap = () => {
|
||||
return useContext(DatabaseContext)?.rowDocMap;
|
||||
};
|
||||
|
||||
export const useIsDatabaseRowPage = () => {
|
||||
return useContext(DatabaseContext)?.isDatabaseRowPage;
|
||||
};
|
||||
|
||||
export const useRow = (rowId: string) => {
|
||||
const rows = useContext(DatabaseContext)?.rowDocMap;
|
||||
const rows = useRowDocMap();
|
||||
|
||||
return rows?.get(rowId)?.getMap(YjsEditorKey.data_section);
|
||||
};
|
||||
|
@ -1,12 +1,19 @@
|
||||
import { FieldId, SortId, YDatabaseField, YjsDatabaseKey, YjsEditorKey, YjsFolderKey } from '@/application/collab.type';
|
||||
import {
|
||||
FieldId,
|
||||
SortId,
|
||||
YDatabaseField,
|
||||
YDoc,
|
||||
YjsDatabaseKey,
|
||||
YjsEditorKey,
|
||||
YjsFolderKey,
|
||||
} from '@/application/collab.type';
|
||||
import { getCell, metaIdFromRowId, MIN_COLUMN_WIDTH } from '@/application/database-yjs/const';
|
||||
import {
|
||||
DatabaseContext,
|
||||
useDatabase,
|
||||
useDatabaseFields,
|
||||
useDatabaseView,
|
||||
useRow,
|
||||
useRowData,
|
||||
useIsDatabaseRowPage,
|
||||
useRowDocMap,
|
||||
useRows,
|
||||
useViewId,
|
||||
} from '@/application/database-yjs/context';
|
||||
@ -18,8 +25,9 @@ import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { parseYDatabaseCellToCell } from '@/components/database/components/cell/cell.parse';
|
||||
import { DateTimeCell } from '@/components/database/components/cell/cell.type';
|
||||
import dayjs from 'dayjs';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { throttle } from 'lodash-es';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Y from 'yjs';
|
||||
import { CalendarLayoutSetting, FieldType, FieldVisibility, Filter, RowMetaKey, SortCondition } from './database.type';
|
||||
|
||||
export interface Column {
|
||||
@ -368,8 +376,9 @@ export function useGroup(groupId: string) {
|
||||
|
||||
export function useRowsByGroup(groupId: string) {
|
||||
const { columns, fieldId } = useGroup(groupId);
|
||||
const rows = useContext(DatabaseContext)?.rowDocMap;
|
||||
const rows = useRowDocMap();
|
||||
const rowOrders = useRowOrdersSelector();
|
||||
|
||||
const fields = useDatabaseFields();
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [groupResult, setGroupResult] = useState<Map<string, Row[]>>(new Map());
|
||||
@ -378,6 +387,8 @@ export function useRowsByGroup(groupId: string) {
|
||||
if (!fieldId || !rowOrders || !rows) return;
|
||||
|
||||
const onConditionsChange = () => {
|
||||
if (rows.size !== rowOrders?.length) return;
|
||||
|
||||
const newResult = new Map<string, Row[]>();
|
||||
|
||||
const field = fields.get(fieldId);
|
||||
@ -400,11 +411,9 @@ export function useRowsByGroup(groupId: string) {
|
||||
|
||||
onConditionsChange();
|
||||
|
||||
const debounceConditionsChange = debounce(onConditionsChange, 200);
|
||||
|
||||
fields.observeDeep(debounceConditionsChange);
|
||||
fields.observeDeep(onConditionsChange);
|
||||
return () => {
|
||||
fields.unobserveDeep(debounceConditionsChange);
|
||||
fields.unobserveDeep(onConditionsChange);
|
||||
};
|
||||
}, [fieldId, fields, rowOrders, rows]);
|
||||
|
||||
@ -419,62 +428,139 @@ export function useRowsByGroup(groupId: string) {
|
||||
}
|
||||
|
||||
export function useRowOrdersSelector() {
|
||||
const rows = useContext(DatabaseContext)?.rowDocMap;
|
||||
const isDatabaseRowPage = useIsDatabaseRowPage();
|
||||
const { rows, clock } = useRowDocMapSelector();
|
||||
const [rowOrders, setRowOrders] = useState<Row[]>();
|
||||
const view = useDatabaseView();
|
||||
const sorts = view?.get(YjsDatabaseKey.sorts);
|
||||
const fields = useDatabaseFields();
|
||||
const filters = view?.get(YjsDatabaseKey.filters);
|
||||
const onConditionsChange = useCallback(() => {
|
||||
const originalRowOrders = view?.get(YjsDatabaseKey.row_orders).toJSON();
|
||||
|
||||
if (!originalRowOrders || !rows) return;
|
||||
|
||||
if (originalRowOrders.length !== rows.size && !isDatabaseRowPage) return;
|
||||
if (sorts?.length === 0 && filters?.length === 0) {
|
||||
setRowOrders(originalRowOrders);
|
||||
return;
|
||||
}
|
||||
|
||||
let rowOrders: Row[] | undefined;
|
||||
|
||||
if (sorts?.length) {
|
||||
rowOrders = sortBy(originalRowOrders, sorts, fields, rows);
|
||||
}
|
||||
|
||||
if (filters?.length) {
|
||||
rowOrders = filterBy(rowOrders ?? originalRowOrders, filters, fields, rows);
|
||||
}
|
||||
|
||||
if (rowOrders) {
|
||||
setRowOrders(rowOrders);
|
||||
} else {
|
||||
setRowOrders(originalRowOrders);
|
||||
}
|
||||
}, [fields, filters, rows, sorts, view, isDatabaseRowPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const onConditionsChange = () => {
|
||||
const originalRowOrders = view?.get(YjsDatabaseKey.row_orders).toJSON();
|
||||
|
||||
if (!originalRowOrders || !rows) return;
|
||||
|
||||
if (sorts?.length === 0 && filters?.length === 0) {
|
||||
setRowOrders(originalRowOrders);
|
||||
return;
|
||||
}
|
||||
|
||||
let rowOrders: Row[] | undefined;
|
||||
|
||||
if (sorts?.length) {
|
||||
rowOrders = sortBy(originalRowOrders, sorts, fields, rows);
|
||||
}
|
||||
|
||||
if (filters?.length) {
|
||||
rowOrders = filterBy(rowOrders ?? originalRowOrders, filters, fields, rows);
|
||||
}
|
||||
|
||||
if (rowOrders) {
|
||||
setRowOrders(rowOrders);
|
||||
} else {
|
||||
setRowOrders(originalRowOrders);
|
||||
}
|
||||
};
|
||||
|
||||
const debounceConditionsChange = debounce(onConditionsChange, 200);
|
||||
|
||||
onConditionsChange();
|
||||
sorts?.observeDeep(debounceConditionsChange);
|
||||
filters?.observeDeep(debounceConditionsChange);
|
||||
fields?.observeDeep(debounceConditionsChange);
|
||||
rows?.observeDeep(debounceConditionsChange);
|
||||
}, [onConditionsChange, clock]);
|
||||
|
||||
useEffect(() => {
|
||||
const throttleChange = throttle(onConditionsChange, 200);
|
||||
|
||||
sorts?.observeDeep(throttleChange);
|
||||
filters?.observeDeep(throttleChange);
|
||||
fields?.observeDeep(throttleChange);
|
||||
|
||||
return () => {
|
||||
sorts?.unobserveDeep(debounceConditionsChange);
|
||||
filters?.unobserveDeep(debounceConditionsChange);
|
||||
fields?.unobserveDeep(debounceConditionsChange);
|
||||
rows?.observeDeep(debounceConditionsChange);
|
||||
sorts?.unobserveDeep(throttleChange);
|
||||
filters?.unobserveDeep(throttleChange);
|
||||
fields?.unobserveDeep(throttleChange);
|
||||
};
|
||||
}, [fields, rows, sorts, filters, view]);
|
||||
}, [onConditionsChange, fields, filters, sorts]);
|
||||
|
||||
return rowOrders;
|
||||
}
|
||||
|
||||
export function useRowDocMapSelector() {
|
||||
const rowMap = useRowDocMap();
|
||||
const [clock, setClock] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rowMap) return;
|
||||
const observerEvent = () => setClock((prev) => prev + 1);
|
||||
|
||||
const rowIds = Array.from(rowMap?.keys() || []);
|
||||
|
||||
rowMap.observe(observerEvent);
|
||||
|
||||
const observers = rowIds.map((rowId) => {
|
||||
return observeDeepRow(rowId, rowMap, observerEvent);
|
||||
});
|
||||
|
||||
return () => {
|
||||
rowMap.unobserve(observerEvent);
|
||||
observers.forEach((observer) => observer());
|
||||
};
|
||||
}, [rowMap]);
|
||||
|
||||
return {
|
||||
rows: rowMap,
|
||||
clock,
|
||||
};
|
||||
}
|
||||
|
||||
export function observeDeepRow(
|
||||
rowId: string,
|
||||
rowMap: Y.Map<YDoc>,
|
||||
observerEvent: () => void,
|
||||
key: YjsEditorKey.meta | YjsEditorKey.database_row = YjsEditorKey.database_row
|
||||
) {
|
||||
const rowSharedRoot = rowMap?.get(rowId)?.getMap(YjsEditorKey.data_section);
|
||||
const row = rowSharedRoot?.get(key);
|
||||
|
||||
rowSharedRoot?.observe(observerEvent);
|
||||
row?.observeDeep(observerEvent);
|
||||
return () => {
|
||||
rowSharedRoot?.unobserve(observerEvent);
|
||||
row?.unobserveDeep(observerEvent);
|
||||
};
|
||||
}
|
||||
|
||||
export function useRowDataSelector(rowId: string) {
|
||||
const rowMap = useRowDocMap();
|
||||
const rowSharedRoot = rowMap?.get(rowId)?.getMap(YjsEditorKey.data_section);
|
||||
const row = rowSharedRoot?.get(YjsEditorKey.database_row);
|
||||
|
||||
const [clock, setClock] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rowMap) return;
|
||||
const onChange = () => {
|
||||
setClock((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const observer = observeDeepRow(rowId, rowMap, onChange);
|
||||
|
||||
rowMap.observe(onChange);
|
||||
|
||||
return () => {
|
||||
rowMap.unobserve(onChange);
|
||||
observer();
|
||||
};
|
||||
}, [rowId, rowMap]);
|
||||
|
||||
return {
|
||||
row,
|
||||
clock,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCellSelector({ rowId, fieldId }: { rowId: string; fieldId: string }) {
|
||||
const row = useRowData(rowId);
|
||||
const { row } = useRowDataSelector(rowId);
|
||||
|
||||
const cell = row?.get(YjsDatabaseKey.cells)?.get(fieldId);
|
||||
const [cellValue, setCellValue] = useState(() => (cell ? parseYDatabaseCellToCell(cell) : undefined));
|
||||
|
||||
@ -504,7 +590,7 @@ export function useCalendarEventsSelector() {
|
||||
const filedId = setting.fieldId;
|
||||
const { field } = useFieldSelector(filedId);
|
||||
const rowOrders = useRowOrdersSelector();
|
||||
const rows = useContext(DatabaseContext)?.rowDocMap;
|
||||
const rows = useRowDocMap();
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [emptyEvents, setEmptyEvents] = useState<CalendarEvent[]>([]);
|
||||
|
||||
@ -610,35 +696,67 @@ export interface RowMeta {
|
||||
isEmptyDocument: boolean;
|
||||
}
|
||||
|
||||
const metaIdMapFromRowIdMap = new Map<string, Map<RowMetaKey, string>>();
|
||||
|
||||
function getMetaIdMap(rowId: string) {
|
||||
const hasMetaIdMap = metaIdMapFromRowIdMap.has(rowId);
|
||||
|
||||
if (!hasMetaIdMap) {
|
||||
const parser = metaIdFromRowId(rowId);
|
||||
const map = new Map<RowMetaKey, string>();
|
||||
|
||||
map.set(RowMetaKey.IconId, parser(RowMetaKey.IconId));
|
||||
map.set(RowMetaKey.CoverId, parser(RowMetaKey.CoverId));
|
||||
map.set(RowMetaKey.DocumentId, parser(RowMetaKey.DocumentId));
|
||||
map.set(RowMetaKey.IsDocumentEmpty, parser(RowMetaKey.IsDocumentEmpty));
|
||||
metaIdMapFromRowIdMap.set(rowId, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
return metaIdMapFromRowIdMap.get(rowId) as Map<RowMetaKey, string>;
|
||||
}
|
||||
|
||||
export const useRowMetaSelector = (rowId: string) => {
|
||||
const [meta, setMeta] = useState<RowMeta | null>();
|
||||
const yMeta = useRow(rowId)?.get(YjsEditorKey.meta);
|
||||
const rowMap = useRowDocMap();
|
||||
|
||||
const updateMeta = useCallback(() => {
|
||||
const metaKeyMap = getMetaIdMap(rowId);
|
||||
|
||||
const iconKey = metaKeyMap.get(RowMetaKey.IconId) ?? '';
|
||||
const coverKey = metaKeyMap.get(RowMetaKey.CoverId) ?? '';
|
||||
const documentId = metaKeyMap.get(RowMetaKey.DocumentId) ?? '';
|
||||
const isEmptyDocumentKey = metaKeyMap.get(RowMetaKey.IsDocumentEmpty) ?? '';
|
||||
const rowSharedRoot = rowMap?.get(rowId)?.getMap(YjsEditorKey.data_section);
|
||||
const yMeta = rowSharedRoot?.get(YjsEditorKey.meta);
|
||||
|
||||
if (!yMeta) return;
|
||||
const metaJson = yMeta.toJSON();
|
||||
|
||||
const icon = metaJson[iconKey];
|
||||
const cover = metaJson[coverKey];
|
||||
const isEmptyDocument = metaJson[isEmptyDocumentKey];
|
||||
|
||||
setMeta({
|
||||
icon,
|
||||
cover,
|
||||
documentId,
|
||||
isEmptyDocument,
|
||||
});
|
||||
}, [rowId, rowMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!yMeta) return;
|
||||
const onChange = () => {
|
||||
const metaJson = yMeta.toJSON();
|
||||
const getData = metaIdFromRowId(rowId);
|
||||
const icon = metaJson[getData(RowMetaKey.IconId)];
|
||||
const cover = metaJson[getData(RowMetaKey.CoverId)];
|
||||
const documentId = getData(RowMetaKey.DocumentId);
|
||||
const isEmptyDocument = metaJson[getData(RowMetaKey.IsDocumentEmpty)];
|
||||
if (!rowMap) return;
|
||||
updateMeta();
|
||||
const observer = observeDeepRow(rowId, rowMap, updateMeta, YjsEditorKey.meta);
|
||||
|
||||
return setMeta({
|
||||
icon,
|
||||
cover,
|
||||
documentId,
|
||||
isEmptyDocument,
|
||||
});
|
||||
};
|
||||
rowMap.observe(updateMeta);
|
||||
|
||||
onChange();
|
||||
|
||||
yMeta.observe(onChange);
|
||||
return () => {
|
||||
yMeta.unobserve(onChange);
|
||||
rowMap.unobserve(updateMeta);
|
||||
observer();
|
||||
};
|
||||
}, [rowId, yMeta]);
|
||||
}, [rowId, rowMap, updateMeta]);
|
||||
|
||||
return meta;
|
||||
};
|
||||
|
@ -1,8 +1,49 @@
|
||||
import { YFolder } from '@/application/collab.type';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { ViewLayout, YFolder, YjsFolderKey } from '@/application/collab.type';
|
||||
import { createContext, useCallback, useContext } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
export const FolderContext = createContext<YFolder | null>(null);
|
||||
export interface Crumb {
|
||||
viewId: string;
|
||||
rowId?: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const FolderContext = createContext<{
|
||||
folder: YFolder | null;
|
||||
onNavigateToView?: (viewId: string) => void;
|
||||
crumbs?: Crumb[];
|
||||
setCrumbs?: React.Dispatch<React.SetStateAction<Crumb[]>>;
|
||||
} | null>(null);
|
||||
|
||||
export const useFolderContext = () => {
|
||||
return useContext(FolderContext);
|
||||
return useContext(FolderContext)?.folder;
|
||||
};
|
||||
|
||||
export const useViewLayout = () => {
|
||||
const folder = useFolderContext();
|
||||
const { objectId } = useParams();
|
||||
const views = folder?.get(YjsFolderKey.views);
|
||||
const view = objectId ? views?.get(objectId) : null;
|
||||
|
||||
return Number(view?.get(YjsFolderKey.layout)) as ViewLayout;
|
||||
};
|
||||
|
||||
export const useNavigateToView = () => {
|
||||
return useContext(FolderContext)?.onNavigateToView;
|
||||
};
|
||||
|
||||
export const useCrumbs = () => {
|
||||
return useContext(FolderContext)?.crumbs;
|
||||
};
|
||||
|
||||
export const usePushCrumb = () => {
|
||||
const { setCrumbs } = useContext(FolderContext) || {};
|
||||
|
||||
return useCallback(
|
||||
(crumb: Crumb) => {
|
||||
setCrumbs?.((prevCrumbs) => [...prevCrumbs, crumb]);
|
||||
},
|
||||
[setCrumbs]
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,8 @@
|
||||
export enum CoverType {
|
||||
NormalColor = 'color',
|
||||
GradientColor = 'gradient',
|
||||
BuildInImage = 'none',
|
||||
CustomImage = 'custom',
|
||||
LocalImage = 'local',
|
||||
UpsplashImage = 'unsplash',
|
||||
}
|
@ -54,10 +54,10 @@ export function useViewSelector(viewId: string) {
|
||||
setView(view || null);
|
||||
const observerEvent = () => setClock((prev) => prev + 1);
|
||||
|
||||
view.observe(observerEvent);
|
||||
view?.observe(observerEvent);
|
||||
|
||||
return () => {
|
||||
view.unobserve(observerEvent);
|
||||
view?.unobserve(observerEvent);
|
||||
};
|
||||
}, [folder, viewId]);
|
||||
|
||||
|
@ -11,6 +11,8 @@ import * as Y from 'yjs';
|
||||
export class JSDatabaseService implements DatabaseService {
|
||||
private loadedDatabaseId: Set<string> = new Set();
|
||||
|
||||
private cacheDatabaseRowDocMap: Map<string, Y.Doc> = new Map();
|
||||
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
@ -23,9 +25,20 @@ export class JSDatabaseService implements DatabaseService {
|
||||
databaseDoc: YDoc;
|
||||
rows: Y.Map<YDoc>;
|
||||
}> {
|
||||
const rootRowsDoc = new Y.Doc();
|
||||
const rowsFolder = rootRowsDoc.getMap();
|
||||
const isLoaded = this.loadedDatabaseId.has(databaseId);
|
||||
|
||||
const rootRowsDoc =
|
||||
this.cacheDatabaseRowDocMap.get(databaseId) ??
|
||||
new Y.Doc({
|
||||
guid: databaseId,
|
||||
});
|
||||
|
||||
if (!this.cacheDatabaseRowDocMap.has(databaseId)) {
|
||||
this.cacheDatabaseRowDocMap.set(databaseId, rootRowsDoc);
|
||||
}
|
||||
|
||||
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
|
||||
|
||||
let databaseDoc: YDoc | undefined = undefined;
|
||||
|
||||
if (isLoaded) {
|
||||
@ -51,13 +64,15 @@ export class JSDatabaseService implements DatabaseService {
|
||||
for (const id of ids) {
|
||||
const { doc } = await getCollabStorage(id, CollabType.DatabaseRow);
|
||||
|
||||
rowsFolder.set(id, doc);
|
||||
if (!rowsFolder.has(id)) {
|
||||
rowsFolder.set(id, doc);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const rows = await this.loadDatabaseRows(workspaceId, ids);
|
||||
|
||||
rows.forEach((row, id) => {
|
||||
rowsFolder.set(id, row);
|
||||
void this.loadDatabaseRows(workspaceId, ids, (id, row) => {
|
||||
if (!rowsFolder.has(id)) {
|
||||
rowsFolder.set(id, row);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -74,19 +89,20 @@ export class JSDatabaseService implements DatabaseService {
|
||||
console.log('Update rows', rowIds);
|
||||
void this.loadDatabaseRows(
|
||||
workspaceId,
|
||||
rowIds.map((item) => item.id)
|
||||
).then((newRows) => {
|
||||
newRows.forEach((row, id) => {
|
||||
rowsFolder.set(id, row);
|
||||
});
|
||||
});
|
||||
rowIds.map((item) => item.id),
|
||||
(rowId: string, rowDoc) => {
|
||||
if (!rowsFolder.has(rowId)) {
|
||||
rowsFolder.set(rowId, rowDoc);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
databaseDoc,
|
||||
rows: rowsFolder as Y.Map<YDoc>,
|
||||
rows: rowsFolder,
|
||||
};
|
||||
}
|
||||
|
||||
@ -144,6 +160,7 @@ export class JSDatabaseService implements DatabaseService {
|
||||
};
|
||||
|
||||
databaseDoc.on('update', handleUpdate);
|
||||
console.log('Database loaded', rows.toJSON());
|
||||
|
||||
return {
|
||||
databaseDoc,
|
||||
@ -151,9 +168,7 @@ export class JSDatabaseService implements DatabaseService {
|
||||
};
|
||||
}
|
||||
|
||||
async loadDatabaseRows(workspaceId: string, rowIds: string[]) {
|
||||
const rows = new Map<string, YDoc>();
|
||||
|
||||
async loadDatabaseRows(workspaceId: string, rowIds: string[], rowCallback: (rowId: string, rowDoc: YDoc) => void) {
|
||||
try {
|
||||
await batchCollabs(
|
||||
workspaceId,
|
||||
@ -161,12 +176,14 @@ export class JSDatabaseService implements DatabaseService {
|
||||
object_id: id,
|
||||
collab_type: CollabType.DatabaseRow,
|
||||
})),
|
||||
(id, rowDoc) => rows.set(id, rowDoc)
|
||||
rowCallback
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
async closeDatabase(databaseId: string) {
|
||||
this.cacheDatabaseRowDocMap.delete(databaseId);
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import * as Y from 'yjs';
|
||||
export async function openCollabDB(docName: string): Promise<YDoc> {
|
||||
const name = `${databasePrefix}_${docName}`;
|
||||
const doc = new Y.Doc();
|
||||
|
||||
const provider = new IndexeddbPersistence(name, doc);
|
||||
|
||||
let resolve: (value: unknown) => void;
|
||||
@ -26,14 +27,6 @@ export async function openCollabDB(docName: string): Promise<YDoc> {
|
||||
return doc as YDoc;
|
||||
}
|
||||
|
||||
export async function deleteCollabDB(docName: string) {
|
||||
const name = `${databasePrefix}_${docName}`;
|
||||
const doc = new Y.Doc();
|
||||
const provider = new IndexeddbPersistence(name, doc);
|
||||
|
||||
await provider.destroy();
|
||||
}
|
||||
|
||||
export function getDBName(id: string, type: string) {
|
||||
const { uuid } = getAuthInfo() || {};
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { CollabType, YDoc, YjsEditorKey } from '@/application/collab.type';
|
||||
import { CollabType, YDoc, YjsEditorKey, YSharedRoot } from '@/application/collab.type';
|
||||
import { getDBName, openCollabDB } from '@/application/services/js-services/db';
|
||||
import { APIService } from '@/application/services/js-services/wasm';
|
||||
import { applyYDoc } from '@/application/ydoc/apply';
|
||||
@ -30,11 +30,28 @@ function collabTypeToDBType(type: CollabType) {
|
||||
}
|
||||
}
|
||||
|
||||
const collabSharedRootKeyMap = {
|
||||
[CollabType.Folder]: YjsEditorKey.folder,
|
||||
[CollabType.Document]: YjsEditorKey.document,
|
||||
[CollabType.Database]: YjsEditorKey.database,
|
||||
[CollabType.WorkspaceDatabase]: YjsEditorKey.workspace_database,
|
||||
[CollabType.DatabaseRow]: YjsEditorKey.database_row,
|
||||
[CollabType.UserAwareness]: YjsEditorKey.user_awareness,
|
||||
[CollabType.Empty]: YjsEditorKey.empty,
|
||||
};
|
||||
|
||||
export async function getCollabStorage(id: string, type: CollabType) {
|
||||
const name = getDBName(id, collabTypeToDBType(type));
|
||||
|
||||
const doc = await openCollabDB(name);
|
||||
const localExist = doc.share.has(YjsEditorKey.data_section);
|
||||
let localExist = false;
|
||||
const existData = doc.share.has(YjsEditorKey.data_section);
|
||||
|
||||
if (existData) {
|
||||
const data = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
|
||||
|
||||
localExist = data.has(collabSharedRootKeyMap[type] as string);
|
||||
}
|
||||
|
||||
return {
|
||||
doc,
|
||||
@ -74,28 +91,27 @@ export async function batchCollabs(
|
||||
for (const item of params) {
|
||||
const { object_id, collab_type } = item;
|
||||
|
||||
const { doc } = await getCollabStorage(object_id, collab_type);
|
||||
const { doc, localExist } = await getCollabStorage(object_id, collab_type);
|
||||
|
||||
if (rowCallback) {
|
||||
if (rowCallback && localExist) {
|
||||
rowCallback(object_id, doc);
|
||||
}
|
||||
}
|
||||
|
||||
// Async fetch collab data and apply to Y.Doc
|
||||
void (async () => {
|
||||
const res = await batchFetchCollab(workspaceId, params);
|
||||
const res = await batchFetchCollab(workspaceId, params);
|
||||
|
||||
for (const id of Object.keys(res)) {
|
||||
const type = params.find((param) => param.object_id === id)?.collab_type;
|
||||
const data = res[id];
|
||||
for (const id of Object.keys(res)) {
|
||||
const type = params.find((param) => param.object_id === id)?.collab_type;
|
||||
const data = res[id];
|
||||
|
||||
if (type === undefined || !data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { doc } = await getCollabStorage(id, type);
|
||||
|
||||
applyYDoc(doc, data);
|
||||
if (type === undefined || !data) {
|
||||
continue;
|
||||
}
|
||||
})();
|
||||
|
||||
const { doc } = await getCollabStorage(id, type);
|
||||
|
||||
applyYDoc(doc, data);
|
||||
|
||||
rowCallback?.(id, doc);
|
||||
}
|
||||
}
|
||||
|
@ -51,6 +51,7 @@ export interface DatabaseService {
|
||||
databaseDoc: YDoc;
|
||||
rows: Y.Map<YDoc>;
|
||||
}>;
|
||||
closeDatabase: (databaseId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UserService {
|
||||
|
@ -7,6 +7,10 @@ export class TauriDatabaseService implements DatabaseService {
|
||||
//
|
||||
}
|
||||
|
||||
async closeDatabase(_databaseId: string) {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
|
||||
async openDatabase(
|
||||
_workspaceId: string,
|
||||
_viewId: string
|
||||
|
@ -59,10 +59,8 @@ export function withYjs<T extends Editor>(
|
||||
doc: Y.Doc,
|
||||
{
|
||||
localOrigin,
|
||||
includeRoot = true,
|
||||
}: {
|
||||
localOrigin: CollabOrigin;
|
||||
includeRoot?: boolean;
|
||||
}
|
||||
): T & YjsEditor {
|
||||
const e = editor as T & YjsEditor;
|
||||
@ -71,7 +69,7 @@ export function withYjs<T extends Editor>(
|
||||
e.sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
|
||||
|
||||
const initializeDocumentContent = () => {
|
||||
const content = yDocToSlateContent(doc, includeRoot);
|
||||
const content = yDocToSlateContent(doc);
|
||||
|
||||
if (!content) {
|
||||
return;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Operation, Node } from 'slate';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export function applySlateOp(ydoc: Y.Doc, slateRoot: Node, op: Operation) {
|
||||
console.log('applySlateOp', op);
|
||||
// transform slate op to yjs op and apply it to ydoc
|
||||
export function applySlateOp(_ydoc: Y.Doc, _slateRoot: Node, _op: Operation) {
|
||||
// console.log('applySlateOp', op);
|
||||
}
|
||||
|
@ -22,8 +22,7 @@ interface BlockJson {
|
||||
external_id?: string;
|
||||
}
|
||||
|
||||
export function yDocToSlateContent(doc: YDoc, includeRoot?: boolean): Element | undefined {
|
||||
console.log(doc);
|
||||
export function yDocToSlateContent(doc: YDoc): Element | undefined {
|
||||
const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
|
||||
|
||||
console.log(sharedRoot.toJSON());
|
||||
@ -107,13 +106,6 @@ export function yDocToSlateContent(doc: YDoc, includeRoot?: boolean): Element |
|
||||
|
||||
if (!result) return;
|
||||
|
||||
if (!includeRoot) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const { children, ...rootNode } = result;
|
||||
|
||||
// load font family
|
||||
if (fontFamilys.length > 0) {
|
||||
window.WebFont?.load({
|
||||
google: {
|
||||
@ -122,21 +114,7 @@ export function yDocToSlateContent(doc: YDoc, includeRoot?: boolean): Element |
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
children: [
|
||||
{
|
||||
...rootNode,
|
||||
children: [
|
||||
{
|
||||
textId: pageId,
|
||||
type: YjsEditorKey.text,
|
||||
children: [{ text: '' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
...children,
|
||||
],
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
export function blockToSlateNode(block: BlockJson): Element {
|
||||
|
@ -1,9 +1,23 @@
|
||||
import { YFolder } from '@/application/collab.type';
|
||||
import { FolderContext } from '@/application/folder-yjs';
|
||||
import { Crumb, FolderContext } from '@/application/folder-yjs';
|
||||
|
||||
export const FolderProvider: React.FC<{ folder: YFolder | null; children?: React.ReactNode }> = ({
|
||||
folder,
|
||||
children,
|
||||
}) => {
|
||||
return <FolderContext.Provider value={folder}>{children}</FolderContext.Provider>;
|
||||
export const FolderProvider: React.FC<{
|
||||
folder: YFolder | null;
|
||||
children?: React.ReactNode;
|
||||
onNavigateToView?: (viewId: string) => void;
|
||||
crumbs?: Crumb[];
|
||||
setCrumbs?: React.Dispatch<React.SetStateAction<Crumb[]>>;
|
||||
}> = ({ folder, children, onNavigateToView, crumbs, setCrumbs }) => {
|
||||
return (
|
||||
<FolderContext.Provider
|
||||
value={{
|
||||
folder,
|
||||
onNavigateToView,
|
||||
crumbs,
|
||||
setCrumbs,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FolderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
@ -2,7 +2,7 @@ import { Button, Dialog, DialogActions, DialogContent, DialogContentText, Dialog
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function RecordNotFound({ open, workspaceId }: { workspaceId: string; open: boolean }) {
|
||||
export function RecordNotFound({ open, workspaceId, title }: { workspaceId: string; open: boolean; title?: string }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
@ -10,13 +10,13 @@ export function RecordNotFound({ open, workspaceId }: { workspaceId: string; ope
|
||||
<DialogTitle>Oops.. something went wrong</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id='alert-dialog-description'>
|
||||
Sorry, the page you are looking for does not exist.
|
||||
{title ? title : 'The record you are looking for does not exist.'}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions className={'flex w-full items-center justify-center'}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigate(`/workspace/${workspaceId}`);
|
||||
navigate(`/view/${workspaceId}`);
|
||||
}}
|
||||
>
|
||||
Go back
|
||||
|
@ -1,17 +1,54 @@
|
||||
import { ViewLayout, YjsFolderKey, YView } from '@/application/collab.type';
|
||||
import { FontLayout, LineHeightLayout, ViewLayout, YjsFolderKey, YView } from '@/application/collab.type';
|
||||
import { useViewSelector } from '@/application/folder-yjs';
|
||||
import React, { useMemo } from 'react';
|
||||
import { CoverType } from '@/application/folder-yjs/folder.type';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ReactComponent as DocumentSvg } from '$icons/16x/document.svg';
|
||||
import { ReactComponent as GridSvg } from '$icons/16x/grid.svg';
|
||||
import { ReactComponent as BoardSvg } from '$icons/16x/board.svg';
|
||||
import { ReactComponent as CalendarSvg } from '$icons/16x/date.svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface PageCover {
|
||||
type: CoverType;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PageExtra {
|
||||
cover: PageCover | null;
|
||||
fontLayout: FontLayout;
|
||||
lineHeightLayout: LineHeightLayout;
|
||||
font?: string;
|
||||
}
|
||||
|
||||
function parseExtra(extra: string): PageExtra {
|
||||
let extraObj;
|
||||
|
||||
try {
|
||||
extraObj = JSON.parse(extra);
|
||||
} catch (e) {
|
||||
extraObj = {};
|
||||
}
|
||||
|
||||
return {
|
||||
cover: extraObj.cover
|
||||
? {
|
||||
type: extraObj.cover.type,
|
||||
value: extraObj.cover.value,
|
||||
}
|
||||
: null,
|
||||
fontLayout: extraObj.font_layout || FontLayout.normal,
|
||||
lineHeightLayout: extraObj.line_height_layout || LineHeightLayout.normal,
|
||||
font: extraObj.font,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePageInfo(id: string) {
|
||||
const { view } = useViewSelector(id);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const layout = view?.get(YjsFolderKey.layout);
|
||||
const icon = view?.get(YjsFolderKey.icon);
|
||||
const extra = view?.get(YjsFolderKey.extra);
|
||||
const name = view?.get(YjsFolderKey.name) || '';
|
||||
const iconObj = useMemo(() => {
|
||||
try {
|
||||
@ -20,6 +57,11 @@ export function usePageInfo(id: string) {
|
||||
return null;
|
||||
}
|
||||
}, [icon]);
|
||||
|
||||
const extraObj = useMemo(() => {
|
||||
return parseExtra(extra || '');
|
||||
}, [extra]);
|
||||
|
||||
const defaultIcon = useMemo(() => {
|
||||
switch (parseInt(layout ?? '0')) {
|
||||
case ViewLayout.Document:
|
||||
@ -37,9 +79,14 @@ export function usePageInfo(id: string) {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(!view);
|
||||
}, [view]);
|
||||
return {
|
||||
icon: iconObj?.value || defaultIcon,
|
||||
name: name || t('menuAppHeader.defaultNewPageName'),
|
||||
view: view as YView,
|
||||
loading,
|
||||
extra: extraObj,
|
||||
};
|
||||
}
|
||||
|
@ -0,0 +1,12 @@
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import React from 'react';
|
||||
|
||||
function ComponentLoading() {
|
||||
return (
|
||||
<div className={'flex h-[260px] w-full items-center justify-center'}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ComponentLoading;
|
@ -39,7 +39,7 @@ function LinearProgressWithLabel({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={'w-[30px] text-center text-xs text-text-title'}>{result}</div>
|
||||
<div className={'min-w-[30px] text-center text-text-title'}>{result}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Scrollbars } from 'react-custom-scrollbars';
|
||||
import { Scrollbars } from 'react-custom-scrollbars-2';
|
||||
import React from 'react';
|
||||
|
||||
export interface AFScrollerProps {
|
||||
@ -18,8 +18,7 @@ export const AFScroller = React.forwardRef(
|
||||
autoHide
|
||||
ref={(el) => {
|
||||
if (!el) return;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
|
||||
const scrollEl = el.container?.firstChild as HTMLElement;
|
||||
|
||||
if (!scrollEl) return;
|
||||
@ -62,7 +61,7 @@ export const AFScroller = React.forwardRef(
|
||||
marginRight: 0,
|
||||
marginBottom: 0,
|
||||
}}
|
||||
className={className}
|
||||
className={`${className} appflowy-custom-scroller`}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
|
@ -8,11 +8,11 @@ export interface TagProps {
|
||||
|
||||
export const Tag: FC<TagProps> = ({ color, size = 'small', label }) => {
|
||||
const className = useMemo(() => {
|
||||
const classList = ['rounded-md', 'font-medium', 'text-xs', 'leading-[18px]'];
|
||||
const classList = ['rounded-md', 'font-medium', 'leading-[18px]'];
|
||||
|
||||
if (color) classList.push(`text-text-title`);
|
||||
if (size === 'small') classList.push('text-xs', 'px-2', 'py-[2px]');
|
||||
if (size === 'medium') classList.push('text-sm', 'px-3', 'py-1');
|
||||
if (size === 'small') classList.push('px-2', 'py-[2px]');
|
||||
if (size === 'medium') classList.push('px-3', 'py-1');
|
||||
return classList.join(' ');
|
||||
}, [color, size]);
|
||||
|
||||
|
@ -9,8 +9,8 @@ const AppMain = withAppWrapper(() => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={'/'} element={<ProtectedRoutes />}>
|
||||
<Route path={'/workspace/:workspaceId'} element={<FolderPage />} />
|
||||
<Route path={'/workspace/:workspaceId/:type/:objectId'} element={<ProductPage />} />
|
||||
<Route path={'/view/:workspaceId'} element={<FolderPage />} />
|
||||
<Route path={'/view/:workspaceId/:objectId'} element={<ProductPage />} />
|
||||
</Route>
|
||||
<Route path={'/login'} element={<LoginPage />} />
|
||||
</Routes>
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { useAppLanguage } from '@/components/app/useAppLanguage';
|
||||
import React, { createContext, useEffect, useMemo, useState } from 'react';
|
||||
import { AFService } from '@/application/services/services.type';
|
||||
import { getService } from '@/application/services';
|
||||
@ -5,15 +6,17 @@ import { useAppSelector } from '@/stores/store';
|
||||
|
||||
export const AFConfigContext = createContext<
|
||||
| {
|
||||
service: AFService | undefined;
|
||||
}
|
||||
service: AFService | undefined;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
|
||||
function AppConfig ({ children }: { children: React.ReactNode }) {
|
||||
function AppConfig({ children }: { children: React.ReactNode }) {
|
||||
const appConfig = useAppSelector((state) => state.app.appConfig);
|
||||
const [service, setService] = useState<AFService>();
|
||||
|
||||
useAppLanguage();
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
if (!appConfig) return;
|
||||
@ -25,7 +28,7 @@ function AppConfig ({ children }: { children: React.ReactNode }) {
|
||||
() => ({
|
||||
service,
|
||||
}),
|
||||
[service],
|
||||
[service]
|
||||
);
|
||||
|
||||
return <AFConfigContext.Provider value={config}>{children}</AFConfigContext.Provider>;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { useAppThemeMode } from '@/components/app/useAppThemeMode';
|
||||
import React, { useMemo } from 'react';
|
||||
import createTheme from '@mui/material/styles/createTheme';
|
||||
import ThemeProvider from '@mui/material/styles/ThemeProvider';
|
||||
@ -7,7 +8,7 @@ import 'src/styles/tailwind.css';
|
||||
import 'src/styles/template.css';
|
||||
|
||||
function AppTheme({ children }: { children: React.ReactNode }) {
|
||||
const isDark = false;
|
||||
const { isDark } = useAppThemeMode();
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
|
@ -0,0 +1,21 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function useAppLanguage() {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const detectLanguageChange = () => {
|
||||
const language = window.navigator.language;
|
||||
|
||||
void i18n.changeLanguage(language);
|
||||
};
|
||||
|
||||
detectLanguageChange();
|
||||
|
||||
window.addEventListener('languagechange', detectLanguageChange);
|
||||
return () => {
|
||||
window.removeEventListener('languagechange', detectLanguageChange);
|
||||
};
|
||||
}, [i18n]);
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useAppThemeMode() {
|
||||
const [isDark, setIsDark] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
function detectColorScheme() {
|
||||
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
setIsDark(darkModeMediaQuery.matches);
|
||||
document.documentElement.setAttribute('data-dark-mode', darkModeMediaQuery.matches ? 'true' : 'false');
|
||||
}
|
||||
|
||||
detectColorScheme();
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', detectColorScheme);
|
||||
return () => {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', detectColorScheme);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isDark,
|
||||
};
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAuth } from '@/components/auth/auth.hooks';
|
||||
import { currentUserActions, LoginState } from '@/stores/currentUser/slice';
|
||||
import { useAppDispatch } from '@/stores/store';
|
||||
@ -42,8 +42,18 @@ function ProtectedRoutes() {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentUser.user?.workspaceId && (window.location.pathname === '/' || window.location.pathname === '')) {
|
||||
navigate(`/view/${currentUser.user.workspaceId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'relative h-screen w-screen'}>
|
||||
<div
|
||||
className={'relative h-screen w-screen bg-bg-body'}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{checked ? (
|
||||
<SplashScreen />
|
||||
) : (
|
||||
@ -53,7 +63,7 @@ function ProtectedRoutes() {
|
||||
)}
|
||||
|
||||
{isLoading && <StartLoading />}
|
||||
<Suspense>{platform.isTauri && <TauriAuth />}</Suspense>
|
||||
{platform.isTauri && <TauriAuth />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -79,7 +89,7 @@ const StartLoading = () => {
|
||||
}, [dispatch]);
|
||||
return (
|
||||
<Portal>
|
||||
<div className={'fixed inset-0 z-[1400] flex h-full w-full items-center justify-center bg-bg-mask bg-opacity-50'}>
|
||||
<div className={'bg-bg-mask fixed inset-0 z-[1400] flex h-full w-full items-center justify-center bg-opacity-50'}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
</Portal>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { YDoc, YjsEditorKey } from '@/application/collab.type';
|
||||
import { YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import { DatabaseContextState } from '@/application/database-yjs';
|
||||
import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import RecordNotFound from '@/components/_shared/not-found/RecordNotFound';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
@ -8,15 +9,14 @@ import { Log } from '@/utils/log';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import React, { memo, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
export const Database = memo(() => {
|
||||
export const Database = memo((props?: { onNavigateToRow?: (viewId: string, rowId: string) => void }) => {
|
||||
const { objectId, workspaceId } = useId() || {};
|
||||
const [search, setSearch] = useSearchParams();
|
||||
|
||||
const viewId = search.get('v');
|
||||
const [doc, setDoc] = useState<YDoc | null>(null);
|
||||
const [rows, setRows] = useState<Y.Map<YDoc> | null>(null); // Map<rowId, YDoc
|
||||
const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>(null); // Map<rowId, YDoc
|
||||
const [notFound, setNotFound] = useState<boolean>(false);
|
||||
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
|
||||
|
||||
@ -52,11 +52,27 @@ export const Database = memo(() => {
|
||||
|
||||
const navigateToRow = useCallback(
|
||||
(rowId: string) => {
|
||||
const currentViewId = objectId || viewId;
|
||||
|
||||
if (props?.onNavigateToRow && currentViewId) {
|
||||
props.onNavigateToRow(currentViewId, rowId);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearch({ r: rowId });
|
||||
},
|
||||
[setSearch]
|
||||
[props, setSearch, viewId, objectId]
|
||||
);
|
||||
|
||||
const databaseId = doc?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database)?.get(YjsDatabaseKey.id) as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (!databaseId || !databaseService) return;
|
||||
return () => {
|
||||
void databaseService.closeDatabase(databaseId);
|
||||
};
|
||||
}, [databaseService, databaseId]);
|
||||
|
||||
if (notFound || !objectId) {
|
||||
return <RecordNotFound open={notFound} workspaceId={workspaceId} />;
|
||||
}
|
||||
@ -74,7 +90,7 @@ export const Database = memo(() => {
|
||||
<DatabaseContextProvider
|
||||
navigateToRow={navigateToRow}
|
||||
viewId={viewId || objectId}
|
||||
doc={doc}
|
||||
databaseDoc={doc}
|
||||
rowDocMap={rows}
|
||||
readOnly={true}
|
||||
>
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { YDoc, YjsEditorKey } from '@/application/collab.type';
|
||||
import { YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import { DatabaseContextState } from '@/application/database-yjs';
|
||||
import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { DatabaseRowProperties, DatabaseRowSubDocument } from '@/components/database/components/database-row';
|
||||
import DatabaseRowHeader from '@/components/database/components/header/DatabaseRowHeader';
|
||||
@ -7,16 +9,16 @@ import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
|
||||
import { Log } from '@/utils/log';
|
||||
import { Divider } from '@mui/material';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import React, { Suspense, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import RecordNotFound from 'src/components/_shared/not-found/RecordNotFound';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
function DatabaseRow({ rowId }: { rowId: string }) {
|
||||
const { objectId, workspaceId } = useId() || {};
|
||||
const [doc, setDoc] = useState<YDoc | null>(null);
|
||||
const [rows, setRows] = useState<Y.Map<YDoc> | null>(null); // Map<rowId, YDoc
|
||||
const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>(null); // Map<rowId, YDoc
|
||||
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
|
||||
const [notFound, setNotFound] = useState<boolean>(false);
|
||||
|
||||
const handleOpenDatabaseRow = useCallback(async () => {
|
||||
if (!databaseService || !workspaceId || !objectId) return;
|
||||
|
||||
@ -24,16 +26,6 @@ function DatabaseRow({ rowId }: { rowId: string }) {
|
||||
setDoc(null);
|
||||
const { databaseDoc, rows } = await databaseService.openDatabase(workspaceId, objectId, [rowId]);
|
||||
|
||||
console.log('database', databaseDoc.getMap(YjsEditorKey.data_section).toJSON());
|
||||
console.log('row', rows.get(rowId)?.getMap(YjsEditorKey.data_section).toJSON());
|
||||
|
||||
const row = rows.get(rowId);
|
||||
|
||||
if (!row) {
|
||||
setNotFound(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setDoc(databaseDoc);
|
||||
setRows(rows);
|
||||
} catch (e) {
|
||||
@ -41,12 +33,20 @@ function DatabaseRow({ rowId }: { rowId: string }) {
|
||||
setNotFound(true);
|
||||
}
|
||||
}, [databaseService, workspaceId, objectId, rowId]);
|
||||
const databaseId = doc?.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database)?.get(YjsDatabaseKey.id) as string;
|
||||
|
||||
useEffect(() => {
|
||||
setNotFound(false);
|
||||
void handleOpenDatabaseRow();
|
||||
}, [handleOpenDatabaseRow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!databaseId || !databaseService) return;
|
||||
return () => {
|
||||
void databaseService.closeDatabase(databaseId);
|
||||
};
|
||||
}, [databaseService, databaseId]);
|
||||
|
||||
if (notFound || !objectId) {
|
||||
return <RecordNotFound open={notFound} workspaceId={workspaceId} />;
|
||||
}
|
||||
@ -60,17 +60,29 @@ function DatabaseRow({ rowId }: { rowId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-col items-center'}>
|
||||
<div className={'max-w-screen relative flex w-[964px] min-w-0 flex-col gap-4'}>
|
||||
<DatabaseContextProvider viewId={objectId} doc={doc} rowDocMap={rows} readOnly={true}>
|
||||
<DatabaseRowHeader rowId={rowId} />
|
||||
<div className={'flex w-full justify-center'}>
|
||||
<div className={'max-w-screen w-[964px] min-w-0'}>
|
||||
<div className={' relative flex flex-col gap-4'}>
|
||||
<DatabaseContextProvider
|
||||
isDatabaseRowPage={true}
|
||||
viewId={objectId}
|
||||
databaseDoc={doc}
|
||||
rowDocMap={rows}
|
||||
readOnly={true}
|
||||
>
|
||||
<DatabaseRowHeader rowId={rowId} />
|
||||
|
||||
<div className={'flex flex-1 flex-col gap-4'}>
|
||||
<DatabaseRowProperties rowId={rowId} />
|
||||
<Divider className={'mx-16 max-md:mx-4'} />
|
||||
<DatabaseRowSubDocument rowId={rowId} />
|
||||
</div>
|
||||
</DatabaseContextProvider>
|
||||
<div className={'flex flex-1 flex-col gap-4'}>
|
||||
<Suspense>
|
||||
<DatabaseRowProperties rowId={rowId} />
|
||||
</Suspense>
|
||||
<Divider className={'mx-16 max-md:mx-4'} />
|
||||
<Suspense fallback={<ComponentLoading />}>
|
||||
<DatabaseRowSubDocument rowId={rowId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</DatabaseContextProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { DatabaseViewLayout, YjsDatabaseKey } from '@/application/collab.type';
|
||||
import { useDatabaseViewsSelector } from '@/application/database-yjs';
|
||||
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
|
||||
import { Board } from '@/components/database/board';
|
||||
import { Calendar } from '@/components/database/calendar';
|
||||
import { DatabaseConditionsContext } from '@/components/database/components/conditions/context';
|
||||
import { DatabaseTabs } from '@/components/database/components/tabs';
|
||||
import { Grid } from '@/components/database/grid';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { ElementFallbackRender } from '@/components/error/ElementFallbackRender';
|
||||
import React, { Suspense, useCallback, useMemo, useState } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import DatabaseConditions from 'src/components/database/components/conditions/DatabaseConditions';
|
||||
|
||||
function DatabaseViews({
|
||||
@ -58,7 +61,11 @@ function DatabaseViews({
|
||||
<DatabaseTabs selectedViewId={currentViewId} setSelectedViewId={onChangeView} viewIds={viewIds} />
|
||||
<DatabaseConditions />
|
||||
</DatabaseConditionsContext.Provider>
|
||||
<div className={'flex h-full w-full flex-1 flex-col overflow-hidden'}>{view}</div>
|
||||
<div className={'flex h-full w-full flex-1 flex-col overflow-hidden'}>
|
||||
<Suspense fallback={<ComponentLoading />}>
|
||||
<ErrorBoundary fallbackRender={ElementFallbackRender}>{view}</ErrorBoundary>
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ import { useDatabase, useGroupsSelector } from '@/application/database-yjs';
|
||||
import { Group } from '@/components/database/components/board';
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { DragDropContext } from 'react-beautiful-dnd';
|
||||
|
||||
export function Board() {
|
||||
const database = useDatabase();
|
||||
@ -17,17 +16,11 @@ export function Board() {
|
||||
}
|
||||
|
||||
return (
|
||||
<DragDropContext
|
||||
onDragEnd={() => {
|
||||
//
|
||||
}}
|
||||
>
|
||||
<div className={'grid-board flex w-full flex-1 flex-col'}>
|
||||
{groups.map((groupId) => (
|
||||
<Group key={groupId} groupId={groupId} />
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
<div className={'grid-board flex w-full flex-1 flex-col'}>
|
||||
{groups.map((groupId) => (
|
||||
<Group key={groupId} groupId={groupId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1 +1,3 @@
|
||||
export * from './Board';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const Board = lazy(() => import('./Board'));
|
||||
|
@ -12,8 +12,7 @@ $today-highlight-bg: transparent;
|
||||
|
||||
|
||||
.rbc-date-cell, .rbc-header {
|
||||
min-width: 120px;
|
||||
max-width: 180px;
|
||||
min-width: 97px;
|
||||
}
|
||||
|
||||
.rbc-date-cell.rbc-now {
|
||||
@ -82,7 +81,8 @@ $today-highlight-bg: transparent;
|
||||
.rbc-month-row {
|
||||
display: inline-table !important;
|
||||
flex: 0 0 0 !important;
|
||||
min-height: 120px !important;
|
||||
min-height: 97px !important;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.event-properties {
|
||||
|
@ -1 +1,3 @@
|
||||
export * from './Calendar';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const Calendar = lazy(() => import('./Calendar'));
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { useFieldsSelector } from '@/application/database-yjs';
|
||||
import { useFieldsSelector, useNavigateToRow } from '@/application/database-yjs';
|
||||
import OpenAction from '@/components/database/components/database-row/OpenAction';
|
||||
import CardField from '@/components/database/components/field/CardField';
|
||||
import { getPlatform } from '@/utils/platform';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
|
||||
export interface CardProps {
|
||||
@ -13,6 +15,7 @@ export function Card({ groupFieldId, rowId, onResize, isDragging }: CardProps) {
|
||||
const fields = useFieldsSelector();
|
||||
const showFields = useMemo(() => fields.filter((field) => field.fieldId !== groupFieldId), [fields, groupFieldId]);
|
||||
|
||||
const [isHovering, setIsHovering] = React.useState(false);
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -32,17 +35,33 @@ export function Card({ groupFieldId, rowId, onResize, isDragging }: CardProps) {
|
||||
};
|
||||
}, [onResize, isDragging]);
|
||||
|
||||
const isMobile = useMemo(() => {
|
||||
return getPlatform().isMobile;
|
||||
}, []);
|
||||
|
||||
const navigateToRow = useNavigateToRow();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
navigateToRow?.(rowId);
|
||||
}
|
||||
}}
|
||||
ref={ref}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
style={{
|
||||
minHeight: '38px',
|
||||
}}
|
||||
className='flex cursor-pointer flex-col rounded-lg border border-line-divider p-3 shadow-sm hover:bg-fill-list-active hover:shadow'
|
||||
className='relative flex cursor-pointer flex-col rounded-lg border border-line-divider p-3 text-xs shadow-sm hover:bg-fill-list-active hover:shadow'
|
||||
>
|
||||
{showFields.map((field, index) => {
|
||||
return <CardField index={index} key={field.fieldId} rowId={rowId} fieldId={field.fieldId} />;
|
||||
})}
|
||||
<div className={`absolute top-1.5 right-1.5 ${isHovering ? 'block' : 'hidden'}`}>
|
||||
<OpenAction rowId={rowId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import ListItem from '@/components/database/components/board/column/ListItem';
|
||||
import { useRenderColumn } from '@/components/database/components/board/column/useRenderColumn';
|
||||
import { useMeasureHeight } from '@/components/database/components/cell/useMeasure';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Draggable, DraggableProvided, Droppable } from 'react-beautiful-dnd';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { VariableSizeList } from 'react-window';
|
||||
|
||||
@ -13,10 +12,9 @@ export interface ColumnProps {
|
||||
id: string;
|
||||
rows?: Row[];
|
||||
fieldId: string;
|
||||
provided: DraggableProvided;
|
||||
}
|
||||
|
||||
export function Column({ id, rows, fieldId, provided }: ColumnProps) {
|
||||
export function Column({ id, rows, fieldId }: ColumnProps) {
|
||||
const { header } = useRenderColumn(id, fieldId);
|
||||
const ref = React.useRef<VariableSizeList | null>(null);
|
||||
const forceUpdate = useCallback((index: number) => {
|
||||
@ -54,13 +52,7 @@ export function Column({ id, rows, fieldId, provided }: ColumnProps) {
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Draggable isDragDisabled draggableId={item.id} index={index} key={item.id}>
|
||||
{(provided) => (
|
||||
<ListItem fieldId={fieldId} onResize={onResizeCallback} provided={provided} item={item} style={style} />
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
return <ListItem fieldId={fieldId} onResize={onResizeCallback} item={item} style={style} />;
|
||||
},
|
||||
[fieldId, onResize]
|
||||
);
|
||||
@ -75,55 +67,32 @@ export function Column({ id, rows, fieldId, provided }: ColumnProps) {
|
||||
},
|
||||
[rowHeight, rows]
|
||||
);
|
||||
const rowCount = rows?.length || 0;
|
||||
|
||||
if (!rows) return <div ref={provided.innerRef} />;
|
||||
return (
|
||||
<div key={id} className='column flex w-[230px] flex-col gap-4' {...provided.draggableProps} ref={provided.innerRef}>
|
||||
<div className='column-header flex h-[24px] items-center text-xs font-medium' {...provided.dragHandleProps}>
|
||||
<div key={id} className='column flex w-[230px] flex-col gap-4'>
|
||||
<div className='column-header flex h-[24px] items-center text-xs font-medium'>
|
||||
<Tag label={header?.name} color={header?.color} />
|
||||
</div>
|
||||
|
||||
<div className={'w-full flex-1 overflow-hidden'}>
|
||||
<Droppable
|
||||
droppableId={`column-${id}`}
|
||||
mode='virtual'
|
||||
renderClone={(provided, snapshot, rubric) => (
|
||||
<ListItem
|
||||
provided={provided}
|
||||
isDragging={snapshot.isDragging}
|
||||
item={rows[rubric.source.index]}
|
||||
fieldId={fieldId}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{(provided, snapshot) => {
|
||||
// Add an extra item to our list to make space for a dragging item
|
||||
// Usually the DroppableProvided.placeholder does this, but that won't
|
||||
// work in a virtual list
|
||||
const itemCount = snapshot.isUsingPlaceholder ? rows.length + 1 : rows.length;
|
||||
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => {
|
||||
return (
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => {
|
||||
return (
|
||||
<VariableSizeList
|
||||
ref={ref}
|
||||
height={height}
|
||||
itemCount={itemCount}
|
||||
itemSize={getItemSize}
|
||||
width={width}
|
||||
outerElementType={AFScroller}
|
||||
outerRef={provided.innerRef}
|
||||
itemData={rows}
|
||||
>
|
||||
{Row}
|
||||
</VariableSizeList>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
<VariableSizeList
|
||||
ref={ref}
|
||||
height={height}
|
||||
itemCount={rowCount}
|
||||
itemSize={getItemSize}
|
||||
width={width}
|
||||
outerElementType={AFScroller}
|
||||
itemData={rows}
|
||||
>
|
||||
{Row}
|
||||
</VariableSizeList>
|
||||
);
|
||||
}}
|
||||
</Droppable>
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,74 +1,29 @@
|
||||
import { Row } from '@/application/database-yjs';
|
||||
import React from 'react';
|
||||
import { DraggableProvided, DraggingStyle, NotDraggingStyle } from 'react-beautiful-dnd';
|
||||
import Card from 'src/components/database/components/board/card/Card';
|
||||
|
||||
export const ListItem = ({
|
||||
provided,
|
||||
item,
|
||||
style,
|
||||
onResize,
|
||||
fieldId,
|
||||
isDragging,
|
||||
}: {
|
||||
provided: DraggableProvided;
|
||||
item: Row;
|
||||
item?: Row;
|
||||
style?: React.CSSProperties;
|
||||
fieldId: string;
|
||||
onResize?: (height: number) => void;
|
||||
isDragging?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={getStyle({
|
||||
draggableStyle: provided.draggableProps.style,
|
||||
virtualStyle: style,
|
||||
isDragging,
|
||||
})}
|
||||
className={`w-full bg-bg-body ${isDragging ? 'is-dragging' : ''}`}
|
||||
style={{
|
||||
...style,
|
||||
width: 'calc(100% - 2px)',
|
||||
}}
|
||||
className={`w-full bg-bg-body`}
|
||||
>
|
||||
<Card onResize={onResize} rowId={item.id} groupFieldId={fieldId} />
|
||||
{item?.id ? <Card onResize={onResize} rowId={item.id} groupFieldId={fieldId} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyle({
|
||||
draggableStyle,
|
||||
virtualStyle,
|
||||
isDragging,
|
||||
}: {
|
||||
draggableStyle?: DraggingStyle | NotDraggingStyle;
|
||||
virtualStyle?: React.CSSProperties;
|
||||
isDragging?: boolean;
|
||||
}) {
|
||||
// If you don't want any spacing between your items
|
||||
// then you could just return this.
|
||||
// I do a little bit of magic to have some nice visual space
|
||||
// between the row items
|
||||
const combined = {
|
||||
...virtualStyle,
|
||||
...draggableStyle,
|
||||
} as {
|
||||
height: number;
|
||||
left: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
// Being lazy: this is defined in our css file
|
||||
const grid = 1;
|
||||
|
||||
// when dragging we want to use the draggable style for placement, otherwise use the virtual style
|
||||
|
||||
return {
|
||||
...combined,
|
||||
height: isDragging ? combined.height : combined.height - grid,
|
||||
left: isDragging ? combined.left : combined.left + grid,
|
||||
width: isDragging ? (draggableStyle as DraggingStyle)?.width : `calc(${combined.width} - ${grid * 2}px)`,
|
||||
marginBottom: grid,
|
||||
};
|
||||
}
|
||||
|
||||
export default ListItem;
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { useRowsByGroup } from '@/application/database-yjs';
|
||||
import { AFScroller } from '@/components/_shared/scroller';
|
||||
import React from 'react';
|
||||
import { Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Column } from '../column';
|
||||
|
||||
@ -26,34 +25,11 @@ export const Group = ({ groupId }: GroupProps) => {
|
||||
if (columns.length === 0 || !fieldId) return null;
|
||||
return (
|
||||
<AFScroller overflowYHidden className={'relative px-16 max-md:px-4'}>
|
||||
<Droppable droppableId={`group-${groupId}`} direction='horizontal' type='column'>
|
||||
{(provided) => {
|
||||
return (
|
||||
<div
|
||||
className='columns flex h-full w-fit gap-4 border-t border-line-divider py-4'
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{columns.map((data, index) => (
|
||||
<Draggable isDragDisabled key={data.id} draggableId={`column-${data.id}`} index={index}>
|
||||
{(provided) => {
|
||||
return (
|
||||
<Column
|
||||
provided={provided}
|
||||
key={data.id}
|
||||
id={data.id}
|
||||
fieldId={fieldId}
|
||||
rows={groupResult.get(data.id)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Droppable>
|
||||
<div className='columns flex h-full w-fit min-w-full gap-4 border-t border-line-divider py-4'>
|
||||
{columns.map((data) => (
|
||||
<Column key={data.id} id={data.id} fieldId={fieldId} rows={groupResult.get(data.id)} />
|
||||
))}
|
||||
</div>
|
||||
</AFScroller>
|
||||
);
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { CalendarEvent, useFieldsSelector } from '@/application/database-yjs';
|
||||
import { CalendarEvent, useFieldsSelector, useNavigateToRow } from '@/application/database-yjs';
|
||||
import { RichTooltip } from '@/components/_shared/popover';
|
||||
import EventPaper from '@/components/database/components/calendar/event/EventPaper';
|
||||
import CardField from '@/components/database/components/field/CardField';
|
||||
@ -11,19 +11,36 @@ export function Event({ event }: EventWrapperProps<CalendarEvent>) {
|
||||
const fields = useFieldsSelector();
|
||||
const showFields = useMemo(() => fields.filter((field) => field.fieldId !== fieldId), [fields, fieldId]);
|
||||
|
||||
const navigateToRow = useNavigateToRow();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className={'px-1 py-0.5'}>
|
||||
<RichTooltip content={<EventPaper rowId={rowId} />} open={open} placement='right' onClose={() => setOpen(false)}>
|
||||
<div
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 768) {
|
||||
navigateToRow?.(rowId);
|
||||
} else {
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
className={
|
||||
'flex min-h-[24px] cursor-pointer flex-col gap-2 rounded-md border border-line-border bg-bg-body p-2 shadow-sm hover:bg-fill-list-active hover:shadow'
|
||||
'flex min-h-[24px] cursor-pointer flex-col gap-2 rounded-md border border-line-border bg-bg-body p-2 text-xs shadow-sm hover:bg-fill-list-active hover:shadow'
|
||||
}
|
||||
>
|
||||
{showFields.map((field) => {
|
||||
return <CardField index={0} key={field.fieldId} rowId={rowId} fieldId={field.fieldId} />;
|
||||
return (
|
||||
<div
|
||||
key={field.fieldId}
|
||||
style={{
|
||||
fontSize: '0.85em',
|
||||
}}
|
||||
className={'overflow-x-hidden truncate'}
|
||||
>
|
||||
<CardField index={0} rowId={rowId} fieldId={field.fieldId} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RichTooltip>
|
||||
|
@ -1,32 +1,22 @@
|
||||
import { useFieldsSelector, useNavigateToRow } from '@/application/database-yjs';
|
||||
import { useFieldsSelector, usePrimaryFieldId } from '@/application/database-yjs';
|
||||
import EventPaperTitle from '@/components/database/components/calendar/event/EventPaperTitle';
|
||||
import OpenAction from '@/components/database/components/database-row/OpenAction';
|
||||
import { Property } from '@/components/database/components/property';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { ReactComponent as ExpandMoreIcon } from '$icons/16x/full_view.svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function EventPaper({ rowId }: { rowId: string }) {
|
||||
const fields = useFieldsSelector();
|
||||
const navigateToRow = useNavigateToRow();
|
||||
const { t } = useTranslation();
|
||||
const primaryFieldId = usePrimaryFieldId();
|
||||
|
||||
const fields = useFieldsSelector().filter((column) => column.fieldId !== primaryFieldId);
|
||||
|
||||
return (
|
||||
<div className={'max-h-[260px] w-[360px] overflow-y-auto'}>
|
||||
<div className={'flex h-fit w-full flex-col items-center justify-center py-2 px-3'}>
|
||||
<div className={'flex w-full items-center justify-end'}>
|
||||
<Tooltip placement={'bottom'} title={t('tooltip.openAsPage')}>
|
||||
<button
|
||||
color={'primary'}
|
||||
className={'rounded bg-bg-body p-1 hover:bg-fill-list-hover'}
|
||||
onClick={() => {
|
||||
navigateToRow?.(rowId);
|
||||
}}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<OpenAction rowId={rowId} />
|
||||
</div>
|
||||
<div className={'event-properties flex w-full flex-1 flex-col gap-4 overflow-y-auto py-2'}>
|
||||
{primaryFieldId && <EventPaperTitle rowId={rowId} fieldId={primaryFieldId} />}
|
||||
{fields.map((field) => {
|
||||
return <Property fieldId={field.fieldId} rowId={rowId} key={field.fieldId} />;
|
||||
})}
|
||||
|
@ -0,0 +1,15 @@
|
||||
import { useCellSelector } from '@/application/database-yjs';
|
||||
import { TextCell } from '@/components/database/components/cell/cell.type';
|
||||
import { TextProperty } from '@/components/database/components/property/text';
|
||||
import React from 'react';
|
||||
|
||||
function EventPaperTitle({ fieldId, rowId }: { fieldId: string; rowId: string }) {
|
||||
const cell = useCellSelector({
|
||||
fieldId,
|
||||
rowId,
|
||||
});
|
||||
|
||||
return <TextProperty cell={cell as TextCell} fieldId={fieldId} rowId={rowId} />;
|
||||
}
|
||||
|
||||
export default EventPaperTitle;
|
@ -33,7 +33,7 @@ function NoDate({ emptyEvents }: { emptyEvents: CalendarEvent[] }) {
|
||||
<Button
|
||||
size={'small'}
|
||||
variant={'outlined'}
|
||||
className={'rounded-md border-line-divider '}
|
||||
className={'whitespace-nowrap rounded-md border-line-divider'}
|
||||
color={'inherit'}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
|
@ -22,8 +22,8 @@ export function Toolbar({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<div className={'text-sm font-medium'}>{dateStr}</div>
|
||||
<div className={'flex items-center justify-between overflow-x-auto overflow-y-hidden'}>
|
||||
<div className={'whitespace-nowrap text-sm font-medium'}>{dateStr}</div>
|
||||
<div className={'flex items-center justify-end gap-2'}>
|
||||
<IconButton size={'small'} onClick={() => onNavigate('PREV')}>
|
||||
<LeftArrow />
|
||||
|
@ -1,10 +1,13 @@
|
||||
import { ReactComponent as CheckboxCheckSvg } from '$icons/16x/check_filled.svg';
|
||||
import { ReactComponent as CheckboxUncheckSvg } from '$icons/16x/uncheck.svg';
|
||||
import { FieldType } from '@/application/database-yjs';
|
||||
import { CellProps, CheckboxCell as CheckboxCellType } from '@/components/database/components/cell/cell.type';
|
||||
|
||||
export function CheckboxCell({ cell, style }: CellProps<CheckboxCellType>) {
|
||||
const checked = cell?.data;
|
||||
|
||||
if (cell?.fieldType !== FieldType.Checkbox) return null;
|
||||
|
||||
return (
|
||||
<div style={style} className='relative flex w-full cursor-pointer items-center text-lg text-fill-default'>
|
||||
{checked ? <CheckboxCheckSvg className={'h-4 w-4'} /> : <CheckboxUncheckSvg className={'h-4 w-4'} />}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { parseChecklistData } from '@/application/database-yjs';
|
||||
import { FieldType, parseChecklistData } from '@/application/database-yjs';
|
||||
import { CellProps, ChecklistCell as ChecklistCellType } from '@/components/database/components/cell/cell.type';
|
||||
import LinearProgressWithLabel from '@/components/_shared/progress/LinearProgressWithLabel';
|
||||
import React, { useMemo } from 'react';
|
||||
@ -11,6 +11,8 @@ export function ChecklistCell({ cell, style, placeholder }: CellProps<ChecklistC
|
||||
const options = data?.options;
|
||||
const selectedOptions = data?.selectedOptionIds;
|
||||
|
||||
if (cell?.fieldType !== FieldType.Checklist) return null;
|
||||
|
||||
if (!data || !options || !selectedOptions)
|
||||
return placeholder ? (
|
||||
<div style={style} className={'text-text-placeholder'}>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { YjsDatabaseKey } from '@/application/collab.type';
|
||||
import { useRowData } from '@/application/database-yjs';
|
||||
import { useRowDataSelector } from '@/application/database-yjs';
|
||||
import { useDateTypeCellDispatcher } from '@/components/database/components/cell/Cell.hooks';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@ -15,14 +15,12 @@ export function RowCreateModifiedTime({
|
||||
attrName: YjsDatabaseKey.last_modified | YjsDatabaseKey.created_at;
|
||||
}) {
|
||||
const { getDateTimeStr } = useDateTypeCellDispatcher(fieldId);
|
||||
const rowData = useRowData(rowId);
|
||||
const { row: rowData } = useRowDataSelector(rowId);
|
||||
const [value, setValue] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rowData) return;
|
||||
const observeHandler = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
setValue(rowData.get(attrName));
|
||||
};
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { FieldType } from '@/application/database-yjs';
|
||||
import { useDateTypeCellDispatcher } from '@/components/database/components/cell/Cell.hooks';
|
||||
import { CellProps, DateTimeCell as DateTimeCellType } from '@/components/database/components/cell/cell.type';
|
||||
import React, { useMemo } from 'react';
|
||||
@ -20,11 +21,12 @@ export function DateTimeCell({ cell, fieldId, style, placeholder }: CellProps<Da
|
||||
}, [cell, getDateTimeStr]);
|
||||
|
||||
const dateStr = useMemo(() => {
|
||||
return [startDateTime, endDateTime].filter(Boolean).join(' -> ');
|
||||
return [startDateTime, endDateTime].filter(Boolean).join(' - ');
|
||||
}, [startDateTime, endDateTime]);
|
||||
|
||||
const hasReminder = !!cell?.reminderId;
|
||||
|
||||
if (cell?.fieldType !== FieldType.DateTime) return null;
|
||||
if (!cell?.data)
|
||||
return placeholder ? (
|
||||
<div style={style} className={'text-text-placeholder'}>
|
||||
|
@ -1,4 +1,10 @@
|
||||
import { currencyFormaterMap, NumberFormat, useFieldSelector, parseNumberTypeOptions } from '@/application/database-yjs';
|
||||
import {
|
||||
currencyFormaterMap,
|
||||
NumberFormat,
|
||||
useFieldSelector,
|
||||
parseNumberTypeOptions,
|
||||
FieldType,
|
||||
} from '@/application/database-yjs';
|
||||
import { CellProps, NumberCell as NumberCellType } from '@/components/database/components/cell/cell.type';
|
||||
import React, { useMemo } from 'react';
|
||||
import Decimal from 'decimal.js';
|
||||
@ -15,7 +21,7 @@ export function NumberCell({ cell, fieldId, style, placeholder }: CellProps<Numb
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => {
|
||||
if (!cell) return '';
|
||||
if (!cell || cell.fieldType !== FieldType.Number) return '';
|
||||
const numberFormater = currencyFormaterMap[format];
|
||||
|
||||
if (!numberFormater) return cell.data;
|
||||
|
@ -1,18 +1,16 @@
|
||||
import { ReactComponent as ExpandMoreIcon } from '$icons/16x/full_view.svg';
|
||||
import { useNavigateToRow, useRowMetaSelector } from '@/application/database-yjs';
|
||||
import { TextCell as CellType, CellProps } from '@/components/database/components/cell/cell.type';
|
||||
import { TextCell } from '@/components/database/components/cell/text';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import OpenAction from '@/components/database/components/database-row/OpenAction';
|
||||
import { getPlatform } from '@/utils/platform';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export function PrimaryCell(props: CellProps<CellType>) {
|
||||
const navigateToRow = useNavigateToRow();
|
||||
const { rowId } = props;
|
||||
const icon = useRowMetaSelector(rowId)?.icon;
|
||||
const meta = useRowMetaSelector(rowId);
|
||||
const icon = meta?.icon;
|
||||
|
||||
const [hover, setHover] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const table = document.querySelector('.grid-table');
|
||||
@ -31,32 +29,42 @@ export function PrimaryCell(props: CellProps<CellType>) {
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
setHover(false);
|
||||
};
|
||||
|
||||
table.addEventListener('mousemove', onMouseMove);
|
||||
table.addEventListener('mouseleave', onMouseLeave);
|
||||
return () => {
|
||||
table.removeEventListener('mousemove', onMouseMove);
|
||||
table.removeEventListener('mouseleave', onMouseLeave);
|
||||
};
|
||||
}, [rowId]);
|
||||
|
||||
const isMobile = useMemo(() => {
|
||||
return getPlatform().isMobile;
|
||||
}, []);
|
||||
|
||||
const navigateToRow = useNavigateToRow();
|
||||
|
||||
return (
|
||||
<div className={'primary-cell relative flex min-h-full w-full items-center gap-2'}>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
navigateToRow?.(rowId);
|
||||
}
|
||||
}}
|
||||
className={'primary-cell relative flex min-h-full w-full items-center gap-2'}
|
||||
>
|
||||
{icon && <div className={'h-4 w-4'}>{icon}</div>}
|
||||
<div className={'flex-1 overflow-x-hidden'}>
|
||||
<TextCell {...props} />
|
||||
</div>
|
||||
|
||||
{hover && (
|
||||
<Tooltip placement={'bottom'} title={t('tooltip.openAsPage')}>
|
||||
<button
|
||||
color={'primary'}
|
||||
className={
|
||||
'absolute right-0 top-1/2 min-w-0 -translate-y-1/2 transform rounded border border-line-divider bg-bg-body p-1 hover:bg-fill-list-hover'
|
||||
}
|
||||
onClick={() => {
|
||||
navigateToRow?.(rowId);
|
||||
}}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className={'absolute right-0 top-1/2 min-w-0 -translate-y-1/2 transform '}>
|
||||
<OpenAction rowId={rowId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,13 +1,17 @@
|
||||
import { FieldType } from '@/application/database-yjs';
|
||||
import { CellProps, RelationCell as RelationCellType } from '@/components/database/components/cell/cell.type';
|
||||
import RelationItems from '@/components/database/components/cell/relation/RelationItems';
|
||||
import React from 'react';
|
||||
|
||||
export function RelationCell({ cell, fieldId, style, placeholder }: CellProps<RelationCellType>) {
|
||||
if (cell?.fieldType !== FieldType.Relation) return null;
|
||||
|
||||
if (!cell?.data)
|
||||
return placeholder ? (
|
||||
<div style={style} className={'text-text-placeholder'}>
|
||||
{placeholder}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return <RelationItems cell={cell} fieldId={fieldId} style={style} />;
|
||||
}
|
||||
|
@ -1,23 +1,33 @@
|
||||
import { YDatabaseField, YDatabaseFields, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import { parseRelationTypeOption, useFieldSelector } from '@/application/database-yjs';
|
||||
import { YDatabaseField, YDatabaseFields, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
|
||||
import {
|
||||
DatabaseContextState,
|
||||
parseRelationTypeOption,
|
||||
useDatabase,
|
||||
useFieldSelector,
|
||||
useNavigateToRow,
|
||||
} from '@/application/database-yjs';
|
||||
import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { RelationCell, RelationCellData } from '@/components/database/components/cell/cell.type';
|
||||
import { RelationPrimaryValue } from '@/components/database/components/cell/relation/RelationPrimaryValue';
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
function RelationItems({ style, cell, fieldId }: { cell: RelationCell; fieldId: string; style?: React.CSSProperties }) {
|
||||
const { field } = useFieldSelector(fieldId);
|
||||
const currentDatabaseId = useDatabase()?.get(YjsDatabaseKey.id);
|
||||
const workspaceId = useId()?.workspaceId;
|
||||
const rowIds = useMemo(() => (cell.data.toJSON() as RelationCellData) ?? [], [cell.data]);
|
||||
const rowIds = useMemo(() => {
|
||||
return (cell.data?.toJSON() as RelationCellData) ?? [];
|
||||
}, [cell.data]);
|
||||
const databaseId = rowIds.length > 0 && field ? parseRelationTypeOption(field).database_id : undefined;
|
||||
const databaseService = useContext(AFConfigContext)?.service?.databaseService;
|
||||
const [databasePrimaryFieldId, setDatabasePrimaryFieldId] = useState<string | undefined>(undefined);
|
||||
const [rows, setRows] = useState<Y.Map<YDoc> | null>();
|
||||
const [rows, setRows] = useState<DatabaseContextState['rowDocMap'] | null>();
|
||||
|
||||
const navigateToRow = useNavigateToRow();
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId || !databaseId) return;
|
||||
if (!workspaceId || !databaseId || !rowIds.length) return;
|
||||
void databaseService?.getDatabase(workspaceId, databaseId, rowIds).then(({ databaseDoc: doc, rows }) => {
|
||||
const fields = doc
|
||||
.getMap(YjsEditorKey.data_section)
|
||||
@ -34,13 +44,28 @@ function RelationItems({ style, cell, fieldId }: { cell: RelationCell; fieldId:
|
||||
});
|
||||
}, [workspaceId, databaseId, databaseService, rowIds]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentDatabaseId !== databaseId && databaseId) {
|
||||
void databaseService?.closeDatabase(databaseId);
|
||||
}
|
||||
};
|
||||
}, [currentDatabaseId, databaseId, databaseService]);
|
||||
|
||||
return (
|
||||
<div style={style} className={'relation-cell flex w-full items-center gap-2'}>
|
||||
{rowIds.map((rowId) => {
|
||||
const rowDoc = rows?.get(rowId);
|
||||
|
||||
return (
|
||||
<div key={rowId} className={'w-full cursor-pointer underline'}>
|
||||
<div
|
||||
key={rowId}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateToRow?.(rowId);
|
||||
}}
|
||||
className={'w-full cursor-pointer underline'}
|
||||
>
|
||||
{rowDoc && databasePrimaryFieldId && (
|
||||
<RelationPrimaryValue rowDoc={rowDoc} fieldId={databasePrimaryFieldId} />
|
||||
)}
|
||||
|
@ -4,9 +4,24 @@ import React, { useEffect, useState } from 'react';
|
||||
|
||||
export function RelationPrimaryValue({ rowDoc, fieldId }: { rowDoc: YDoc; fieldId: FieldId }) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [row, setRow] = useState<YDatabaseRow | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const row = rowDoc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database_row) as YDatabaseRow;
|
||||
const data = rowDoc.getMap(YjsEditorKey.data_section);
|
||||
|
||||
const onRowChange = () => {
|
||||
setRow(data?.get(YjsEditorKey.database_row) as YDatabaseRow);
|
||||
};
|
||||
|
||||
onRowChange();
|
||||
data?.observe(onRowChange);
|
||||
return () => {
|
||||
data?.unobserve(onRowChange);
|
||||
};
|
||||
}, [rowDoc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!row) return;
|
||||
const cells = row.get(YjsDatabaseKey.cells);
|
||||
const primaryCell = cells.get(fieldId);
|
||||
|
||||
@ -21,7 +36,7 @@ export function RelationPrimaryValue({ rowDoc, fieldId }: { rowDoc: YDoc; fieldI
|
||||
return () => {
|
||||
primaryCell.unobserve(observeHandler);
|
||||
};
|
||||
}, [rowDoc, fieldId]);
|
||||
}, [row, fieldId]);
|
||||
|
||||
return <div>{text}</div>;
|
||||
}
|
||||
|
@ -2,10 +2,15 @@ import { useReadOnly } from '@/application/database-yjs';
|
||||
import { CellProps, TextCell as TextCellType } from '@/components/database/components/cell/cell.type';
|
||||
import React from 'react';
|
||||
|
||||
export function TextCell({ cell, style }: CellProps<TextCellType>) {
|
||||
export function TextCell({ cell, style, placeholder }: CellProps<TextCellType>) {
|
||||
const readOnly = useReadOnly();
|
||||
|
||||
if (!cell?.data) return null;
|
||||
if (!cell?.data)
|
||||
return placeholder ? (
|
||||
<div style={style} className={'text-text-placeholder'}>
|
||||
{placeholder}
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<div style={style} className={`text-cell w-full cursor-text leading-[1.2] ${readOnly ? 'select-text' : ''}`}>
|
||||
{cell?.data}
|
||||
|
@ -30,9 +30,10 @@ export function UrlCell({ cell, style, placeholder }: CellProps<UrlCellType>) {
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (!isUrl || !cell) return;
|
||||
if (readOnly) {
|
||||
e.stopPropagation();
|
||||
void openUrl(cell.data, '_blank');
|
||||
}
|
||||
}}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { DatabaseViewLayout, YjsDatabaseKey } from '@/application/collab.type';
|
||||
import { useDatabaseView, useFiltersSelector, useSortsSelector } from '@/application/database-yjs';
|
||||
import { useFiltersSelector, useSortsSelector } from '@/application/database-yjs';
|
||||
import { useConditionsContext } from '@/components/database/components/conditions/context';
|
||||
import { TextButton } from '@/components/database/components/tabs/TextButton';
|
||||
import React from 'react';
|
||||
@ -7,16 +6,11 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function DatabaseActions() {
|
||||
const { t } = useTranslation();
|
||||
const view = useDatabaseView();
|
||||
const layout = Number(view?.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
|
||||
|
||||
const sorts = useSortsSelector();
|
||||
const filter = useFiltersSelector();
|
||||
const conditionsContext = useConditionsContext();
|
||||
|
||||
if (layout === DatabaseViewLayout.Calendar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex w-[120px] items-center justify-end gap-1.5'>
|
||||
<TextButton
|
||||
|
@ -3,17 +3,17 @@ import { useRowMetaSelector } from '@/application/database-yjs';
|
||||
import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { Editor } from '@/components/editor';
|
||||
import { Log } from '@/utils/log';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import RecordNotFound from '@/components/_shared/not-found/RecordNotFound';
|
||||
|
||||
export function DatabaseRowSubDocument({ rowId }: { rowId: string }) {
|
||||
const { workspaceId } = useId() || {};
|
||||
const documentId = useRowMetaSelector(rowId)?.documentId;
|
||||
const meta = useRowMetaSelector(rowId);
|
||||
const documentId = meta?.documentId;
|
||||
|
||||
console.log('documentId', documentId);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [doc, setDoc] = useState<YDoc | null>(null);
|
||||
const [notFound, setNotFound] = useState<boolean>(false);
|
||||
|
||||
const documentService = useContext(AFConfigContext)?.service?.documentService;
|
||||
|
||||
@ -23,31 +23,30 @@ export function DatabaseRowSubDocument({ rowId }: { rowId: string }) {
|
||||
setDoc(null);
|
||||
const doc = await documentService.openDocument(workspaceId, documentId);
|
||||
|
||||
console.log('doc', doc);
|
||||
setDoc(doc);
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
setNotFound(true);
|
||||
console.error(e);
|
||||
// haven't created by client, ignore error and show empty
|
||||
}
|
||||
}, [documentService, workspaceId, documentId]);
|
||||
|
||||
useEffect(() => {
|
||||
setNotFound(false);
|
||||
void handleOpenDocument();
|
||||
setLoading(true);
|
||||
void handleOpenDocument().then(() => setLoading(false));
|
||||
}, [handleOpenDocument]);
|
||||
|
||||
if (notFound || !documentId) {
|
||||
return <RecordNotFound open={notFound} workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
if (!doc) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={'flex h-full w-full items-center justify-center'}>
|
||||
<div className={'flex h-[260px] w-full items-center justify-center'}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Editor doc={doc} readOnly={true} includeRoot={false} />;
|
||||
if (!doc) return null;
|
||||
|
||||
return <Editor doc={doc} readOnly={true} />;
|
||||
}
|
||||
|
||||
export default DatabaseRowSubDocument;
|
||||
|
@ -0,0 +1,27 @@
|
||||
import { ReactComponent as ExpandMoreIcon } from '$icons/16x/full_view.svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigateToRow } from '@/application/database-yjs';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import React from 'react';
|
||||
|
||||
function OpenAction({ rowId }: { rowId: string }) {
|
||||
const navigateToRow = useNavigateToRow();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip placement={'bottom'} title={t('tooltip.openAsPage')}>
|
||||
<button
|
||||
color={'primary'}
|
||||
className={'rounded border border-line-divider bg-bg-body p-1 hover:bg-fill-list-hover'}
|
||||
onClick={() => {
|
||||
navigateToRow?.(rowId);
|
||||
}}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpenAction;
|
@ -14,13 +14,11 @@ function CardField({ rowId, fieldId, index }: { rowId: string; fieldId: string;
|
||||
|
||||
const isPrimary = field?.get(YjsDatabaseKey.is_primary);
|
||||
const style = useMemo(() => {
|
||||
const styleProperties = {
|
||||
fontSize: '12px',
|
||||
};
|
||||
const styleProperties = {};
|
||||
|
||||
if (isPrimary) {
|
||||
Object.assign(styleProperties, {
|
||||
fontSize: '14px',
|
||||
fontSize: '1.25em',
|
||||
fontWeight: 500,
|
||||
});
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { YjsDatabaseKey } from '@/application/collab.type';
|
||||
import { FieldType } from '@/application/database-yjs/database.type';
|
||||
import { Column, useFieldSelector } from '@/application/database-yjs/selector';
|
||||
import { FieldTypeIcon } from '@/components/database/components/field';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
export function GridColumn({ column, index }: { column: Column; index: number }) {
|
||||
@ -16,19 +17,21 @@ export function GridColumn({ column, index }: { column: Column; index: number })
|
||||
}, [field]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderLeftWidth: index === 1 ? 0 : 1,
|
||||
}}
|
||||
className={
|
||||
'flex h-full w-full cursor-pointer items-center overflow-hidden whitespace-nowrap border-t border-b border-l border-line-divider px-1.5 text-xs font-medium hover:bg-fill-list-active'
|
||||
}
|
||||
>
|
||||
<div className={'w-5'}>
|
||||
<FieldTypeIcon type={type} className={'mr-1 h-4 w-4'} />
|
||||
<Tooltip title={name} enterNextDelay={1000} placement={'right'}>
|
||||
<div
|
||||
style={{
|
||||
borderLeftWidth: index === 1 ? 0 : 1,
|
||||
}}
|
||||
className={
|
||||
'flex h-full w-full cursor-pointer items-center overflow-hidden whitespace-nowrap border-t border-b border-l border-line-divider px-1.5 text-xs font-medium hover:bg-fill-list-active'
|
||||
}
|
||||
>
|
||||
<div className={'w-5'}>
|
||||
<FieldTypeIcon type={type} className={'mr-1 h-4 w-4'} />
|
||||
</div>
|
||||
<div className={'flex-1'}>{name}</div>
|
||||
</div>
|
||||
<div className={'flex-1'}>{name}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -22,7 +22,6 @@ export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: Gr
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
console.log(ref.current, scrollLeft);
|
||||
ref.current.scrollTo({ scrollLeft });
|
||||
}
|
||||
}, [scrollLeft]);
|
||||
|
@ -1,15 +1,35 @@
|
||||
import { useCellSelector, usePrimaryFieldId, useRowMetaSelector } from '@/application/database-yjs';
|
||||
import { useCellSelector, useDatabaseViewId, usePrimaryFieldId, useRowMetaSelector } from '@/application/database-yjs';
|
||||
import { FolderContext } from '@/application/folder-yjs';
|
||||
import Title from '@/components/database/components/header/Title';
|
||||
import React from 'react';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
|
||||
function DatabaseRowHeader({ rowId }: { rowId: string }) {
|
||||
const fieldId = usePrimaryFieldId() || '';
|
||||
const setCrumbs = useContext(FolderContext)?.setCrumbs;
|
||||
const viewId = useDatabaseViewId();
|
||||
|
||||
const meta = useRowMetaSelector(rowId);
|
||||
const cell = useCellSelector({
|
||||
rowId,
|
||||
fieldId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewId) return;
|
||||
setCrumbs?.((prev) => {
|
||||
const lastCrumb = prev[prev.length - 1];
|
||||
const crumb = {
|
||||
viewId,
|
||||
rowId,
|
||||
name: cell?.data as string,
|
||||
icon: meta?.icon || '',
|
||||
};
|
||||
|
||||
if (lastCrumb?.rowId === rowId) return [...prev.slice(0, -1), crumb];
|
||||
return [...prev, crumb];
|
||||
});
|
||||
}, [cell, meta, rowId, setCrumbs, viewId]);
|
||||
|
||||
return <Title icon={meta?.icon} name={cell?.data as string} />;
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { YjsDatabaseKey } from '@/application/collab.type';
|
||||
import { FieldType, useCellSelector, useFieldSelector } from '@/application/database-yjs';
|
||||
import { Cell as CellType, CellProps, TextCell } from '@/components/database/components/cell/cell.type';
|
||||
import { Cell as CellType, CellProps } from '@/components/database/components/cell/cell.type';
|
||||
import { CheckboxCell } from '@/components/database/components/cell/checkbox';
|
||||
import { RowCreateModifiedTime } from '@/components/database/components/cell/created-modified';
|
||||
import { DateTimeCell } from '@/components/database/components/cell/date';
|
||||
import { NumberCell } from '@/components/database/components/cell/number';
|
||||
import { RelationCell } from '@/components/database/components/cell/relation';
|
||||
import { SelectOptionCell } from '@/components/database/components/cell/select-option';
|
||||
import { TextCell } from '@/components/database/components/cell/text';
|
||||
import { UrlCell } from '@/components/database/components/cell/url';
|
||||
import PropertyWrapper from '@/components/database/components/property/PropertyWrapper';
|
||||
import { TextProperty } from '@/components/database/components/property/text';
|
||||
@ -42,6 +43,8 @@ export function Property({ fieldId, rowId }: { fieldId: string; rowId: string })
|
||||
return ChecklistProperty;
|
||||
case FieldType.Relation:
|
||||
return RelationCell;
|
||||
case FieldType.RichText:
|
||||
return TextCell;
|
||||
default:
|
||||
return TextProperty;
|
||||
}
|
||||
@ -54,10 +57,6 @@ export function Property({ fieldId, rowId }: { fieldId: string; rowId: string })
|
||||
[]
|
||||
);
|
||||
|
||||
if (fieldType === FieldType.RichText) {
|
||||
return <TextProperty cell={cell as TextCell} fieldId={fieldId} rowId={rowId} />;
|
||||
}
|
||||
|
||||
if (fieldType === FieldType.CreatedTime || fieldType === FieldType.LastEditedTime) {
|
||||
const attrName = fieldType === FieldType.CreatedTime ? YjsDatabaseKey.created_at : YjsDatabaseKey.last_modified;
|
||||
|
||||
|
@ -7,7 +7,7 @@ function PropertyWrapper({ fieldId, children }: { fieldId: string; children: Rea
|
||||
<div className={'property-label flex h-[28px] w-[30%] items-center'}>
|
||||
<FieldDisplay fieldId={fieldId} />
|
||||
</div>
|
||||
<div className={'flex flex-1 flex-wrap pr-1'}>{children}</div>
|
||||
<div className={'flex flex-1 flex-wrap items-center overflow-x-hidden pr-1'}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ export function ChecklistProperty(props: CellProps<CellType>) {
|
||||
const selectedOptions = data?.selectedOptionIds;
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-col gap-2'}>
|
||||
<div className={'flex w-full flex-col gap-2 py-2'}>
|
||||
<ChecklistCell {...props} />
|
||||
{options?.map((option) => {
|
||||
const isSelected = selectedOptions?.includes(option.id);
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { ViewLayout, YjsFolderKey, YView } from '@/application/collab.type';
|
||||
import { DatabaseViewLayout, ViewLayout, YjsDatabaseKey, YjsFolderKey, YView } from '@/application/collab.type';
|
||||
import { useDatabaseView } from '@/application/database-yjs';
|
||||
import { useFolderContext } from '@/application/folder-yjs';
|
||||
import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { DatabaseActions } from '@/components/database/components/conditions';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import { forwardRef, FunctionComponent, SVGProps, useCallback, useEffect, useMemo } from 'react';
|
||||
import { ViewTabs, ViewTab } from './ViewTabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@ -31,6 +33,9 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
|
||||
const objectId = useId().objectId;
|
||||
const { t } = useTranslation();
|
||||
const folder = useFolderContext();
|
||||
const view = useDatabaseView();
|
||||
const layout = Number(view?.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
|
||||
|
||||
const handleChange = (_: React.SyntheticEvent, newValue: string) => {
|
||||
setSelectedViewId?.(newValue);
|
||||
};
|
||||
@ -50,12 +55,21 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
|
||||
[folder]
|
||||
);
|
||||
|
||||
const className = useMemo(() => {
|
||||
const classList = [
|
||||
'mx-16 -mb-[0.5px] flex items-center overflow-hidden border-line-divider text-text-title max-md:mx-4',
|
||||
];
|
||||
|
||||
if (layout === DatabaseViewLayout.Calendar) {
|
||||
classList.push('border-b');
|
||||
}
|
||||
|
||||
return classList.join(' ');
|
||||
}, [layout]);
|
||||
|
||||
if (viewIds.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className='mx-16 -mb-[0.5px] flex items-center overflow-hidden border-b border-line-divider text-text-title max-md:mx-4'
|
||||
>
|
||||
<div ref={ref} className={className}>
|
||||
<div
|
||||
style={{
|
||||
width: 'calc(100% - 120px)',
|
||||
@ -83,14 +97,18 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
|
||||
icon={<Icon className={'h-4 w-4'} />}
|
||||
iconPosition='start'
|
||||
color='inherit'
|
||||
label={<span className={'max-w-[120px] truncate'}>{name || t('grid.title.placeholder')}</span>}
|
||||
label={
|
||||
<Tooltip title={name} placement={'right'}>
|
||||
<span className={'max-w-[120px] truncate'}>{name || t('grid.title.placeholder')}</span>
|
||||
</Tooltip>
|
||||
}
|
||||
value={viewId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ViewTabs>
|
||||
</div>
|
||||
<DatabaseActions />
|
||||
{layout !== DatabaseViewLayout.Calendar ? <DatabaseActions /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { RowsContext, useDatabase, useRowOrdersSelector, useViewId } from '@/application/database-yjs';
|
||||
import { useRenderFields, GridHeader, GridTable } from '@/components/database/components/grid';
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export function Grid() {
|
||||
const database = useDatabase();
|
||||
@ -11,6 +11,10 @@ export function Grid() {
|
||||
const { fields, columnWidth } = useRenderFields();
|
||||
const rowOrders = useRowOrdersSelector();
|
||||
|
||||
useEffect(() => {
|
||||
setScrollLeft(0);
|
||||
}, [viewId]);
|
||||
|
||||
if (!database || !rowOrders) {
|
||||
return (
|
||||
<div className={'flex w-full flex-1 flex-col items-center justify-center'}>
|
||||
|
@ -1,17 +1,29 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
|
||||
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { DocumentHeader } from '@/components/document/document_header';
|
||||
import { Editor } from '@/components/editor';
|
||||
import { EditorLayoutStyle } from '@/components/editor/EditorContext';
|
||||
import { Log } from '@/utils/log';
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import React, { Suspense, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import RecordNotFound from 'src/components/_shared/not-found/RecordNotFound';
|
||||
|
||||
export const Document = () => {
|
||||
const { objectId: documentId, workspaceId } = useId() || {};
|
||||
const [doc, setDoc] = useState<YDoc | null>(null);
|
||||
const [notFound, setNotFound] = useState<boolean>(false);
|
||||
const extra = usePageInfo(documentId).extra;
|
||||
|
||||
const layoutStyle: EditorLayoutStyle = useMemo(() => {
|
||||
return {
|
||||
font: extra?.font || '',
|
||||
fontLayout: extra?.fontLayout,
|
||||
lineHeightLayout: extra?.lineHeightLayout,
|
||||
};
|
||||
}, [extra]);
|
||||
const documentService = useContext(AFConfigContext)?.service?.documentService;
|
||||
|
||||
const handleOpenDocument = useCallback(async () => {
|
||||
@ -32,19 +44,65 @@ export const Document = () => {
|
||||
void handleOpenDocument();
|
||||
}, [handleOpenDocument]);
|
||||
|
||||
const style = useMemo(() => {
|
||||
const fontSizeMap = {
|
||||
small: '14px',
|
||||
normal: '16px',
|
||||
large: '20px',
|
||||
};
|
||||
|
||||
return {
|
||||
fontFamily: layoutStyle.font,
|
||||
fontSize: fontSizeMap[layoutStyle.fontLayout],
|
||||
};
|
||||
}, [layoutStyle]);
|
||||
|
||||
const layoutClassName = useMemo(() => {
|
||||
const classList = [];
|
||||
|
||||
if (layoutStyle.fontLayout === 'large') {
|
||||
classList.push('font-large');
|
||||
} else if (layoutStyle.fontLayout === 'small') {
|
||||
classList.push('font-small');
|
||||
}
|
||||
|
||||
if (layoutStyle.lineHeightLayout === 'large') {
|
||||
classList.push('line-height-large');
|
||||
} else if (layoutStyle.lineHeightLayout === 'small') {
|
||||
classList.push('line-height-small');
|
||||
}
|
||||
|
||||
return classList.join(' ');
|
||||
}, [layoutStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutStyle.font) return;
|
||||
void window.WebFont?.load({
|
||||
google: {
|
||||
families: [layoutStyle.font],
|
||||
},
|
||||
});
|
||||
}, [layoutStyle.font]);
|
||||
|
||||
if (!documentId) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{doc && (
|
||||
<div className={'relative w-full'}>
|
||||
{doc ? (
|
||||
<div style={style} className={`relative w-full ${layoutClassName}`}>
|
||||
<DocumentHeader doc={doc} viewId={documentId} />
|
||||
<div className={'flex w-full justify-center'}>
|
||||
<div className={'max-w-screen w-[964px] min-w-0'}>
|
||||
<Editor doc={doc} readOnly={true} includeRoot={true} />
|
||||
</div>
|
||||
<Suspense fallback={<ComponentLoading />}>
|
||||
<div className={'max-w-screen w-[964px] min-w-0'}>
|
||||
<Editor doc={doc} readOnly={true} layoutStyle={layoutStyle} />
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={'flex h-full w-full items-center justify-center'}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecordNotFound open={notFound} workspaceId={workspaceId} />
|
||||
|
@ -1,11 +1,20 @@
|
||||
import { CoverType, YDoc } from '@/application/collab.type';
|
||||
import { DocCoverType, YDoc } from '@/application/collab.type';
|
||||
import { CoverType } from '@/application/folder-yjs/folder.type';
|
||||
import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
|
||||
import { useBlockCover } from '@/components/document/document_header/useBlockCover';
|
||||
import { showColorsForImage } from '@/components/document/document_header/utils';
|
||||
import { renderColor } from '@/utils/color';
|
||||
import React, { useCallback } from 'react';
|
||||
import DefaultImage from './default_cover.jpg';
|
||||
|
||||
function DocumentCover({ doc }: { doc: YDoc }) {
|
||||
function DocumentCover({ doc, onTextColor }: { doc: YDoc; onTextColor: (color: string) => void }) {
|
||||
const viewId = useId().objectId;
|
||||
const { extra } = usePageInfo(viewId);
|
||||
|
||||
const pageCover = extra.cover;
|
||||
const { cover } = useBlockCover(doc);
|
||||
|
||||
const renderCoverColor = useCallback((color: string) => {
|
||||
return (
|
||||
<div
|
||||
@ -17,21 +26,47 @@ function DocumentCover({ doc }: { doc: YDoc }) {
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderCoverImage = useCallback((url: string) => {
|
||||
return <img draggable={false} src={url} alt={''} className={'h-full w-full object-cover'} />;
|
||||
}, []);
|
||||
const renderCoverImage = useCallback(
|
||||
(url: string) => {
|
||||
return (
|
||||
<img
|
||||
onLoad={(e) => {
|
||||
void showColorsForImage(e.currentTarget).then((res) => {
|
||||
onTextColor(res);
|
||||
});
|
||||
}}
|
||||
draggable={false}
|
||||
src={url}
|
||||
alt={''}
|
||||
className={'h-full w-full object-cover'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[onTextColor]
|
||||
);
|
||||
|
||||
const { cover_selection_type: type, cover_selection: value = '' } = cover || {};
|
||||
|
||||
return value ? (
|
||||
<div className={`relative mb-[-80px] flex h-[255px] w-full`}>
|
||||
<>
|
||||
{type === CoverType.Asset ? renderCoverImage(DefaultImage) : null}
|
||||
{type === CoverType.Color ? renderCoverColor(value) : null}
|
||||
{type === CoverType.Image ? renderCoverImage(value) : null}
|
||||
</>
|
||||
if (!pageCover && !cover?.cover_selection) return null;
|
||||
return (
|
||||
<div className={`relative flex h-[255px] w-full max-sm:h-[180px]`}>
|
||||
{pageCover ? (
|
||||
<>
|
||||
{[CoverType.NormalColor, CoverType.GradientColor].includes(pageCover.type)
|
||||
? renderCoverColor(pageCover.value)
|
||||
: null}
|
||||
{CoverType.BuildInImage === pageCover.type ? renderCoverImage(DefaultImage) : null}
|
||||
{[CoverType.CustomImage, CoverType.UpsplashImage].includes(pageCover.type)
|
||||
? renderCoverImage(pageCover.value)
|
||||
: null}
|
||||
</>
|
||||
) : cover?.cover_selection ? (
|
||||
<>
|
||||
{cover.cover_selection_type === DocCoverType.Asset ? renderCoverImage(DefaultImage) : null}
|
||||
{cover.cover_selection_type === DocCoverType.Color ? renderCoverColor(cover.cover_selection) : null}
|
||||
{cover.cover_selection_type === DocCoverType.Image ? renderCoverImage(cover.cover_selection) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentCover;
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { YDoc, YjsFolderKey } from '@/application/collab.type';
|
||||
import { useViewSelector } from '@/application/folder-yjs';
|
||||
import DocumentCover from '@/components/document/document_header/DocumentCover';
|
||||
import React, { memo, useMemo, useRef } from 'react';
|
||||
import React, { memo, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export function DocumentHeader({ viewId, doc }: { viewId: string; doc: YDoc }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { view } = useViewSelector(viewId);
|
||||
|
||||
const [textColor, setTextColor] = useState<string>('var(--text-title)');
|
||||
const icon = view?.get(YjsFolderKey.icon);
|
||||
const iconObject = useMemo(() => {
|
||||
try {
|
||||
@ -17,21 +17,30 @@ export function DocumentHeader({ viewId, doc }: { viewId: string; doc: YDoc }) {
|
||||
}, [icon]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={'document-header select-none'}>
|
||||
<div className={'flex flex-col justify-end'}>
|
||||
<div className={'view-banner flex w-full flex-col overflow-hidden'}>
|
||||
<DocumentCover doc={doc} />
|
||||
<div ref={ref} className={'document-header mb-[10px] select-none'}>
|
||||
<div className={'view-banner relative flex w-full flex-col overflow-hidden'}>
|
||||
<DocumentCover onTextColor={setTextColor} doc={doc} />
|
||||
|
||||
<div className={`relative min-h-[65px] w-[964px] min-w-0 max-w-full px-16 pt-10 max-md:px-4`}>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
bottom: '50%',
|
||||
}}
|
||||
>
|
||||
<div className={`view-icon`}>{iconObject?.value}</div>
|
||||
<div className={`relative mx-16 w-[964px] min-w-0 max-w-full overflow-visible max-md:mx-4`}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
className={'flex items-center gap-2 px-14 pb-10 text-4xl max-md:px-2 max-md:pb-6 max-sm:text-[7vw]'}
|
||||
>
|
||||
<div className={`view-icon`}>{iconObject?.value}</div>
|
||||
<div className={'flex flex-1 items-center gap-2 overflow-hidden'}>
|
||||
<div
|
||||
style={{
|
||||
color: textColor,
|
||||
}}
|
||||
className={'font-bold leading-[1.5em]'}
|
||||
>
|
||||
{view?.get(YjsFolderKey.name)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={'py-2'}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PageCover, YBlocks, YDoc, YDocument, YjsEditorKey } from '@/application/collab.type';
|
||||
import { DocCover, YBlocks, YDoc, YDocument, YjsEditorKey } from '@/application/collab.type';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export function useBlockCover(doc: YDoc) {
|
||||
@ -22,7 +22,7 @@ export function useBlockCover(doc: YDoc) {
|
||||
};
|
||||
}, [doc]);
|
||||
|
||||
const coverObj: PageCover = useMemo(() => {
|
||||
const coverObj: DocCover = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(cover || '');
|
||||
} catch (e) {
|
||||
|
@ -0,0 +1,28 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
import ColorThief from 'colorthief';
|
||||
|
||||
const colorThief = new ColorThief();
|
||||
|
||||
export function calculateTextColor(rgb: [number, number, number]): string {
|
||||
const [r, g, b] = rgb;
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
|
||||
return brightness > 125 ? 'black' : 'white';
|
||||
}
|
||||
|
||||
export async function showColorsForImage(image: HTMLImageElement) {
|
||||
const img = new Image();
|
||||
|
||||
img.crossOrigin = 'Anonymous'; // Handle CORS
|
||||
img.src = image.src;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
});
|
||||
|
||||
const dominantColor = colorThief.getColor(img);
|
||||
|
||||
return calculateTextColor(dominantColor);
|
||||
}
|
@ -1,8 +1,5 @@
|
||||
import { CollabOrigin, YjsFolderKey } from '@/application/collab.type';
|
||||
import { useViewSelector } from '@/application/folder-yjs';
|
||||
import { CollabOrigin } from '@/application/collab.type';
|
||||
import { withYjs, YjsEditor } from '@/application/slate-yjs/plugins/withYjs';
|
||||
import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { CustomEditor } from '@/components/editor/command';
|
||||
import EditorEditable from '@/components/editor/Editable';
|
||||
import { useEditorContext } from '@/components/editor/EditorContext';
|
||||
import { withPlugins } from '@/components/editor/plugins';
|
||||
@ -13,10 +10,7 @@ import * as Y from 'yjs';
|
||||
|
||||
const defaultInitialValue: Descendant[] = [];
|
||||
|
||||
function CollaborativeEditor({ doc, includeRoot = true }: { doc: Y.Doc; includeRoot?: boolean }) {
|
||||
const viewId = useId()?.objectId || '';
|
||||
const { view } = useViewSelector(viewId);
|
||||
const title = includeRoot ? view?.get(YjsFolderKey.name) : undefined;
|
||||
function CollaborativeEditor({ doc }: { doc: Y.Doc }) {
|
||||
const context = useEditorContext();
|
||||
// if readOnly, collabOrigin is Local, otherwise RemoteSync
|
||||
const localOrigin = context.readOnly ? CollabOrigin.Local : CollabOrigin.LocalSync;
|
||||
@ -27,13 +21,12 @@ function CollaborativeEditor({ doc, includeRoot = true }: { doc: Y.Doc; includeR
|
||||
withReact(
|
||||
withYjs(createEditor(), doc, {
|
||||
localOrigin,
|
||||
includeRoot,
|
||||
})
|
||||
)
|
||||
) as YjsEditor),
|
||||
[doc, localOrigin, includeRoot]
|
||||
[doc, localOrigin]
|
||||
);
|
||||
const [connected, setIsConnected] = useState(false);
|
||||
const [, setIsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
@ -45,11 +38,6 @@ function CollaborativeEditor({ doc, includeRoot = true }: { doc: Y.Doc; includeR
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !connected || title === undefined) return;
|
||||
CustomEditor.setDocumentTitle(editor, title);
|
||||
}, [editor, title, connected]);
|
||||
|
||||
return (
|
||||
<Slate editor={editor} initialValue={defaultInitialValue}>
|
||||
<EditorEditable editor={editor} />
|
||||
|
@ -1,21 +1,19 @@
|
||||
import { YDoc } from '@/application/collab.type';
|
||||
import CollaborativeEditor from '@/components/editor/CollaborativeEditor';
|
||||
import { EditorContextProvider } from '@/components/editor/EditorContext';
|
||||
import { defaultLayoutStyle, EditorContextProvider, EditorLayoutStyle } from '@/components/editor/EditorContext';
|
||||
import React from 'react';
|
||||
import './editor.scss';
|
||||
|
||||
export const Editor = ({
|
||||
readOnly,
|
||||
doc,
|
||||
includeRoot = true,
|
||||
}: {
|
||||
export interface EditorProps {
|
||||
readOnly: boolean;
|
||||
doc: YDoc;
|
||||
includeRoot?: boolean;
|
||||
}) => {
|
||||
layoutStyle?: EditorLayoutStyle;
|
||||
}
|
||||
|
||||
export const Editor = ({ readOnly, doc, layoutStyle = defaultLayoutStyle }: EditorProps) => {
|
||||
return (
|
||||
<EditorContextProvider readOnly={readOnly}>
|
||||
<CollaborativeEditor doc={doc} includeRoot={includeRoot} />
|
||||
<EditorContextProvider layoutStyle={layoutStyle} readOnly={readOnly}>
|
||||
<CollaborativeEditor doc={doc} />
|
||||
</EditorContextProvider>
|
||||
);
|
||||
};
|
||||
|
@ -1,11 +1,26 @@
|
||||
import { FontLayout, LineHeightLayout } from '@/application/collab.type';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface EditorLayoutStyle {
|
||||
fontLayout: FontLayout;
|
||||
font: string;
|
||||
lineHeightLayout: LineHeightLayout;
|
||||
}
|
||||
|
||||
export const defaultLayoutStyle: EditorLayoutStyle = {
|
||||
fontLayout: FontLayout.normal,
|
||||
font: '',
|
||||
lineHeightLayout: LineHeightLayout.normal,
|
||||
};
|
||||
|
||||
interface EditorContextState {
|
||||
readOnly: boolean;
|
||||
layoutStyle: EditorLayoutStyle;
|
||||
}
|
||||
|
||||
export const EditorContext = createContext<EditorContextState>({
|
||||
readOnly: true,
|
||||
layoutStyle: defaultLayoutStyle,
|
||||
});
|
||||
|
||||
export const EditorContextProvider = ({ children, ...props }: EditorContextState & { children: React.ReactNode }) => {
|
||||
|
@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
function BoardBlock() {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
export default BoardBlock;
|
@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
function CalendarBlock() {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
export default CalendarBlock;
|
@ -1,7 +1,10 @@
|
||||
import { ReactComponent as ExpandMoreIcon } from '$icons/16x/full_view.svg';
|
||||
import { useNavigateToView } from '@/application/folder-yjs';
|
||||
import { IdProvider, useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { Database } from '@/components/database';
|
||||
import { DatabaseNode, EditorElementProps } from '@/components/editor/editor.type';
|
||||
import React, { forwardRef, memo, useMemo } from 'react';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BlockType } from '@/application/collab.type';
|
||||
|
||||
@ -11,6 +14,8 @@ export const DatabaseBlock = memo(
|
||||
const viewId = node.data.view_id;
|
||||
const workspaceId = useId()?.workspaceId;
|
||||
const type = node.type;
|
||||
const navigateToView = useNavigateToView();
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
const style = useMemo(() => {
|
||||
const style = {};
|
||||
@ -31,16 +36,45 @@ export const DatabaseBlock = memo(
|
||||
return style;
|
||||
}, [type]);
|
||||
|
||||
const handleNavigateToRow = useCallback(
|
||||
(viewId: string, rowId: string) => {
|
||||
const url = `/view/${workspaceId}/${viewId}?r=${rowId}`;
|
||||
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
[workspaceId]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...attributes} className={`relative w-full cursor-pointer py-2`}>
|
||||
<div
|
||||
{...attributes}
|
||||
className={`relative w-full cursor-pointer py-2`}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<div ref={ref} className={'absolute left-0 top-0 h-full w-full caret-transparent'}>
|
||||
{children}
|
||||
</div>
|
||||
<div contentEditable={false} style={style} className={`container-bg flex w-full flex-col px-3`}>
|
||||
<div contentEditable={false} style={style} className={`container-bg relative flex w-full flex-col px-3`}>
|
||||
{viewId ? (
|
||||
<IdProvider workspaceId={workspaceId} objectId={viewId}>
|
||||
<Database />
|
||||
<Database onNavigateToRow={handleNavigateToRow} />
|
||||
{isHovering && (
|
||||
<div className={'absolute right-4 top-1'}>
|
||||
<Tooltip placement={'bottom'} title={t('tooltip.openAsPage')}>
|
||||
<button
|
||||
color={'primary'}
|
||||
className={'rounded border border-line-divider bg-bg-body p-1 hover:bg-fill-list-hover'}
|
||||
onClick={() => {
|
||||
navigateToView?.(viewId);
|
||||
}}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</IdProvider>
|
||||
) : (
|
||||
<div
|
||||
|
@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
function GridBlock() {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
export default GridBlock;
|
@ -1,17 +1,17 @@
|
||||
export function getHeadingCssProperty(level: number) {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return 'text-3xl pt-[10px] pb-[8px] font-bold';
|
||||
return 'text-3xl pt-[10px] max-md:pt-[1.5vw] pb-[4px] max-md:pb-[1vw] font-bold max-sm:text-[6vw]';
|
||||
case 2:
|
||||
return 'text-2xl pt-[8px] pb-[6px] font-bold';
|
||||
return 'text-2xl pt-[8px] max-md:pt-[1vw] pb-[2px] max-md:pb-[0.5vw] font-bold max-sm:text-[5vw]';
|
||||
case 3:
|
||||
return 'text-xl pt-[4px] font-bold';
|
||||
return 'text-xl pt-[4px] font-bold max-sm:text-[4vw]';
|
||||
case 4:
|
||||
return 'text-lg pt-[4px] font-bold';
|
||||
case 5:
|
||||
return 'text-base pt-[4px] font-bold';
|
||||
return 'pt-[4px] font-bold';
|
||||
case 6:
|
||||
return 'text-sm pt-[4px] font-bold';
|
||||
return 'pt-[4px] font-bold';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
export const MathEquation = lazy(() => import('./MathEquation?chunkName=formula'));
|
||||
export const MathEquation = lazy(() => import('./MathEquation'));
|
||||
|
@ -10,6 +10,7 @@ function Placeholder({ node, ...attributes }: { node: Element; className?: strin
|
||||
const { t } = useTranslation();
|
||||
const { readOnly } = useEditorContext();
|
||||
const editor = useSlate();
|
||||
|
||||
const selected = useSelected() && !readOnly && !!editor.selection && Range.isCollapsed(editor.selection);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const block = useMemo(() => {
|
||||
@ -33,7 +34,7 @@ function Placeholder({ node, ...attributes }: { node: Element; className?: strin
|
||||
const unSelectedPlaceholder = useMemo(() => {
|
||||
switch (block?.type) {
|
||||
case BlockType.Paragraph: {
|
||||
if (editor.children.length === 1) {
|
||||
if (editor.children.length === 1 && !readOnly) {
|
||||
return t('editor.slashPlaceHolder');
|
||||
}
|
||||
|
||||
@ -73,7 +74,7 @@ function Placeholder({ node, ...attributes }: { node: Element; className?: strin
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}, [block, t, editor.children.length]);
|
||||
}, [readOnly, block, t, editor.children.length]);
|
||||
|
||||
const selectedPlaceholder = useMemo(() => {
|
||||
switch (block?.type) {
|
||||
@ -122,7 +123,7 @@ function Placeholder({ node, ...attributes }: { node: Element; className?: strin
|
||||
|
||||
return (
|
||||
<span
|
||||
data-placeholder={selected ? selectedPlaceholder : unSelectedPlaceholder}
|
||||
data-placeholder={selected && !readOnly ? selectedPlaceholder : unSelectedPlaceholder}
|
||||
contentEditable={false}
|
||||
{...attributes}
|
||||
className={className}
|
||||
|
@ -11,13 +11,13 @@ export const Text = memo(
|
||||
const { hasStartIcon, renderIcon } = useStartIcon(node);
|
||||
const editor = useSlateStatic();
|
||||
const isEmpty = editor.isEmpty(node);
|
||||
const className = useMemo(
|
||||
() =>
|
||||
`text-element relative my-1 flex w-full whitespace-pre-wrap break-all px-1 ${classNameProp ?? ''} ${
|
||||
hasStartIcon ? 'has-start-icon' : ''
|
||||
}`,
|
||||
[classNameProp, hasStartIcon]
|
||||
);
|
||||
const className = useMemo(() => {
|
||||
const classList = ['text-element', 'relative', 'flex', 'w-full', 'whitespace-pre-wrap', 'break-all', 'px-1'];
|
||||
|
||||
if (classNameProp) classList.push(classNameProp);
|
||||
if (hasStartIcon) classList.push('has-start-icon');
|
||||
return classList.join(' ');
|
||||
}, [classNameProp, hasStartIcon]);
|
||||
|
||||
return (
|
||||
<span {...attributes} ref={ref} className={className}>
|
||||
|
@ -7,7 +7,7 @@ export const TodoList = memo(
|
||||
const className = useMemo(() => {
|
||||
return `flex w-full flex-col ${checked ? 'checked' : ''} ${attributes.className ?? ''}`;
|
||||
}, [attributes.className, checked]);
|
||||
|
||||
|
||||
return (
|
||||
<div {...attributes} ref={ref} className={className}>
|
||||
{children}
|
||||
|
@ -13,6 +13,9 @@ import { Paragraph } from '@/components/editor/components/blocks/paragraph';
|
||||
import { Quote } from '@/components/editor/components/blocks/quote';
|
||||
import { TableBlock, TableCellBlock } from '@/components/editor/components/blocks/table';
|
||||
import { Text } from '@/components/editor/components/blocks/text';
|
||||
import { ElementFallbackRender } from '@/components/error/ElementFallbackRender';
|
||||
import { Skeleton } from '@mui/material';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { TodoList } from 'src/components/editor/components/blocks/todo-list';
|
||||
import { ToggleList } from 'src/components/editor/components/blocks/toggle-list';
|
||||
import { UnSupportedBlock } from '@/components/editor/components/element/UnSupportedBlock';
|
||||
@ -20,7 +23,7 @@ import { Formula } from '@/components/editor/components/leaf/formula';
|
||||
import { Mention } from '@/components/editor/components/leaf/mention';
|
||||
import { EditorElementProps, TextNode } from '@/components/editor/editor.type';
|
||||
import { renderColor } from '@/utils/color';
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import React, { FC, Suspense, useMemo } from 'react';
|
||||
import { RenderElementProps } from 'slate-react';
|
||||
import { DatabaseBlock } from 'src/components/editor/components/blocks/database';
|
||||
|
||||
@ -118,10 +121,14 @@ export const Element = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...attributes} data-block-type={node.type} className={className}>
|
||||
<Component style={style} className={`flex w-full flex-col`} node={node}>
|
||||
{children}
|
||||
</Component>
|
||||
</div>
|
||||
<Suspense fallback={<Skeleton width={'100%'} height={24} />}>
|
||||
<ErrorBoundary fallbackRender={ElementFallbackRender}>
|
||||
<div {...attributes} data-block-type={node.type} className={className}>
|
||||
<Component style={style} className={`flex w-full flex-col`} node={node}>
|
||||
{children}
|
||||
</Component>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
@ -1,20 +1,15 @@
|
||||
import { layoutMap, ViewLayout, YjsFolderKey } from '@/application/collab.type';
|
||||
import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { useNavigateToView } from '@/application/folder-yjs';
|
||||
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function MentionPage({ pageId }: { pageId: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { workspaceId } = useId();
|
||||
const { view, icon, name } = usePageInfo(pageId);
|
||||
const onNavigateToView = useNavigateToView();
|
||||
const { icon, name } = usePageInfo(pageId);
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={() => {
|
||||
const layout = parseInt(view?.get(YjsFolderKey.layout) ?? '0') as ViewLayout;
|
||||
|
||||
navigate(`/workspace/${workspaceId}/${layoutMap[layout]}/${pageId}`);
|
||||
onNavigateToView?.(pageId);
|
||||
}}
|
||||
className={`mention-inline px-1 underline`}
|
||||
contentEditable={false}
|
||||
|
@ -52,6 +52,7 @@
|
||||
|
||||
[role=textbox] {
|
||||
.text-element {
|
||||
@apply my-1;
|
||||
&::selection {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
@ -209,11 +210,6 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
|
||||
}
|
||||
|
||||
|
||||
.grid-block .grid-scroll-container::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.image-render {
|
||||
.image-resizer {
|
||||
@apply absolute w-[10px] top-0 z-10 flex h-full cursor-col-resize items-center justify-end;
|
||||
@ -269,11 +265,39 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
|
||||
}
|
||||
|
||||
.mention-content {
|
||||
@apply ml-5;
|
||||
@apply ml-6;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.text-block-icon {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.font-small {
|
||||
.text-element {
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.font-large {
|
||||
.text-element {
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
.line-height-large {
|
||||
.text-element {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.line-height-small {
|
||||
.text-element {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,11 @@
|
||||
import { Alert } from '@mui/material';
|
||||
import { FallbackProps } from 'react-error-boundary';
|
||||
|
||||
export function ElementFallbackRender({ error }: FallbackProps) {
|
||||
return (
|
||||
<Alert severity={'error'} variant={'standard'} className={'my-2'}>
|
||||
<p>Something went wrong:</p>
|
||||
<pre>{error.message}</pre>
|
||||
</Alert>
|
||||
);
|
||||
}
|
@ -1,19 +1,15 @@
|
||||
import { layoutMap, ViewLayout, YjsFolderKey } from '@/application/collab.type';
|
||||
import { useNavigateToView } from '@/application/folder-yjs';
|
||||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import Page from '@/components/_shared/page/Page';
|
||||
|
||||
function ViewItem({ id }: { id: string }) {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const onNavigateToView = useNavigateToView();
|
||||
|
||||
return (
|
||||
<div className={'cursor-pointer border-b border-line-border py-4 px-2'}>
|
||||
<Page
|
||||
onClick={(view) => {
|
||||
const layout = parseInt(view?.get(YjsFolderKey.layout) ?? '0') as ViewLayout;
|
||||
|
||||
navigate(`${pathname}/${layoutMap[layout]}/${id}`);
|
||||
onClick={() => {
|
||||
onNavigateToView?.(id);
|
||||
}}
|
||||
id={id}
|
||||
/>
|
||||
|
@ -2,10 +2,9 @@ import { downloadPage, openAppFlowySchema, openUrl } from '@/utils/url';
|
||||
import { Button } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Page from 'src/components/_shared/page/Page';
|
||||
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
||||
import Popover, { PopoverOrigin } from '@mui/material/Popover';
|
||||
import Breadcrumb from 'src/components/layout/breadcrumb/Breadcrumb';
|
||||
|
||||
const popoverOrigin: {
|
||||
anchorOrigin: PopoverOrigin;
|
||||
@ -22,14 +21,13 @@ const popoverOrigin: {
|
||||
};
|
||||
|
||||
function Header() {
|
||||
const { objectId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
|
||||
|
||||
return (
|
||||
<div className={'appflowy-top-bar flex h-[64px] p-4'}>
|
||||
<div className={'flex flex-1 items-center justify-between'}>
|
||||
<div className={'flex-1'}>{objectId && <Page id={objectId} />}</div>
|
||||
<div className={'flex w-full items-center justify-between overflow-hidden'}>
|
||||
<Breadcrumb />
|
||||
|
||||
<Button
|
||||
className={'border-line-border'}
|
||||
|
@ -0,0 +1,96 @@
|
||||
import { YFolder, YjsEditorKey, YjsFolderKey } from '@/application/collab.type';
|
||||
import { Crumb } from '@/application/folder-yjs';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
|
||||
export function useLayout() {
|
||||
const { workspaceId, objectId } = useParams();
|
||||
const [search] = useSearchParams();
|
||||
const folderService = useContext(AFConfigContext)?.service?.folderService;
|
||||
const [folder, setFolder] = useState<YFolder | null>(null);
|
||||
const views = folder?.get(YjsFolderKey.views);
|
||||
const view = objectId ? views?.get(objectId) : null;
|
||||
const [crumbs, setCrumbs] = useState<Crumb[]>([]);
|
||||
|
||||
const getFolder = useCallback(
|
||||
async (workspaceId: string) => {
|
||||
const folder = (await folderService?.openWorkspace(workspaceId))
|
||||
?.getMap(YjsEditorKey.data_section)
|
||||
.get(YjsEditorKey.folder);
|
||||
|
||||
if (!folder) return;
|
||||
|
||||
console.log(folder.toJSON());
|
||||
setFolder(folder);
|
||||
},
|
||||
[folderService]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return;
|
||||
|
||||
void getFolder(workspaceId);
|
||||
}, [getFolder, workspaceId]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleNavigateToView = useCallback(
|
||||
(viewId: string) => {
|
||||
const view = folder?.get(YjsFolderKey.views)?.get(viewId);
|
||||
|
||||
if (!view) return;
|
||||
navigate(`/view/${workspaceId}/${viewId}`);
|
||||
},
|
||||
[folder, navigate, workspaceId]
|
||||
);
|
||||
|
||||
const onChangeBreadcrumb = useCallback(() => {
|
||||
if (!view) return;
|
||||
const queue = [view];
|
||||
let parentId = view.get(YjsFolderKey.bid);
|
||||
|
||||
while (parentId) {
|
||||
const parent = views?.get(parentId);
|
||||
|
||||
if (!parent) break;
|
||||
|
||||
queue.unshift(parent);
|
||||
parentId = parent?.get(YjsFolderKey.bid);
|
||||
}
|
||||
|
||||
setCrumbs(
|
||||
queue
|
||||
.map((view) => {
|
||||
let icon = view.get(YjsFolderKey.icon);
|
||||
|
||||
try {
|
||||
icon = JSON.parse(icon || '')?.value;
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return {
|
||||
viewId: view.get(YjsFolderKey.id),
|
||||
name: view.get(YjsFolderKey.name),
|
||||
icon: icon || '',
|
||||
};
|
||||
})
|
||||
.slice(1)
|
||||
);
|
||||
}, [view, views]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeBreadcrumb();
|
||||
|
||||
view?.observe(onChangeBreadcrumb);
|
||||
views?.observe(onChangeBreadcrumb);
|
||||
|
||||
return () => {
|
||||
view?.unobserve(onChangeBreadcrumb);
|
||||
views?.unobserve(onChangeBreadcrumb);
|
||||
};
|
||||
}, [search, onChangeBreadcrumb, view, views]);
|
||||
|
||||
return { folder, handleNavigateToView, crumbs, setCrumbs };
|
||||
}
|
@ -1,37 +1,23 @@
|
||||
import { YFolder, YjsEditorKey } from '@/application/collab.type';
|
||||
import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import Header from '@/components/layout/Header';
|
||||
import { AFScroller } from '@/components/_shared/scroller';
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useLayout } from '@/components/layout/Layout.hooks';
|
||||
import React from 'react';
|
||||
import './layout.scss';
|
||||
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
||||
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { workspaceId } = useParams();
|
||||
const folderService = useContext(AFConfigContext)?.service?.folderService;
|
||||
const [folder, setFolder] = useState<YFolder | null>(null);
|
||||
const getFolder = useCallback(
|
||||
async (workspaceId: string) => {
|
||||
const folder = (await folderService?.openWorkspace(workspaceId))
|
||||
?.getMap(YjsEditorKey.data_section)
|
||||
.get(YjsEditorKey.folder);
|
||||
const { folder, handleNavigateToView, crumbs, setCrumbs } = useLayout();
|
||||
|
||||
if (!folder) return;
|
||||
if (!folder)
|
||||
return (
|
||||
<div className={'flex h-screen w-screen items-center justify-center'}>
|
||||
<Logo className={'h-20 w-20'} />
|
||||
</div>
|
||||
);
|
||||
|
||||
console.log(folder.toJSON());
|
||||
setFolder(folder);
|
||||
},
|
||||
[folderService]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return;
|
||||
|
||||
void getFolder(workspaceId);
|
||||
}, [getFolder, workspaceId]);
|
||||
return (
|
||||
<FolderProvider folder={folder}>
|
||||
<FolderProvider setCrumbs={setCrumbs} crumbs={crumbs} onNavigateToView={handleNavigateToView} folder={folder}>
|
||||
<Header />
|
||||
<AFScroller
|
||||
overflowXHidden
|
||||
|
@ -0,0 +1,24 @@
|
||||
import { useCrumbs } from '@/application/folder-yjs';
|
||||
import Item from '@/components/layout/breadcrumb/Item';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
export function Breadcrumb() {
|
||||
const crumbs = useCrumbs();
|
||||
|
||||
const renderCrumb = useMemo(() => {
|
||||
return crumbs?.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={crumb.viewId}>
|
||||
<Item crumb={crumb} disableClick={isLast} />
|
||||
{!isLast && <span>/</span>}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
}, [crumbs]);
|
||||
|
||||
return <div className={'flex flex-1 items-center gap-2 overflow-hidden'}>{renderCrumb}</div>;
|
||||
}
|
||||
|
||||
export default Breadcrumb;
|
@ -0,0 +1,27 @@
|
||||
import { Crumb, useNavigateToView } from '@/application/folder-yjs';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function Item({ crumb, disableClick = false }: { crumb: Crumb; disableClick?: boolean }) {
|
||||
const { viewId, icon, name } = crumb;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const onNavigateToView = useNavigateToView();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-1 ${!disableClick ? 'cursor-pointer' : 'flex-1 overflow-hidden'}`}
|
||||
onClick={() => {
|
||||
if (disableClick) return;
|
||||
onNavigateToView?.(viewId);
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span className={!disableClick ? 'underline' : 'flex-1 truncate'}>
|
||||
{name || t('menuAppHeader.defaultNewPageName')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Item;
|
@ -0,0 +1 @@
|
||||
export * from './Breadcrumb';
|
@ -16,29 +16,33 @@
|
||||
box-shadow: var(--line-border) 0px 0px 0px 1px inset !important;
|
||||
}
|
||||
|
||||
.appflowy-date-picker-calendar {
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
|
||||
.grid-sticky-header::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.grid-scroll-container::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
|
||||
.appflowy-scroll-container {
|
||||
@mixin hidden-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none; // For Firefox
|
||||
}
|
||||
|
||||
body {
|
||||
&[data-os="windows"]:not([data-browser="firefox"]) {
|
||||
.appflowy-custom-scroller {
|
||||
@include hidden-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-sticky-header {
|
||||
@include hidden-scrollbar;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.appflowy-date-picker-calendar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.appflowy-scrollbar-thumb-horizontal, .appflowy-scrollbar-thumb-vertical {
|
||||
background-color: var(--scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
@ -46,16 +50,8 @@
|
||||
}
|
||||
|
||||
|
||||
.workspaces, .database-conditions, .grid-scroll-table, .grid-board, .MuiPaper-root, .appflowy-database {
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.view-icon {
|
||||
@apply flex w-fit cursor-pointer rounded-lg py-2 text-6xl;
|
||||
@apply flex w-fit leading-[1.5em] cursor-pointer rounded-lg py-2 text-[1.5em];
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols;
|
||||
line-height: 1em;
|
||||
white-space: nowrap;
|
||||
|
@ -13,7 +13,7 @@ function LoginPage() {
|
||||
const workspaceId = currentUser.user?.workspaceId;
|
||||
|
||||
if (!redirect || redirect === '/') {
|
||||
return navigate(`/workspace/${workspaceId}`);
|
||||
return navigate(`/view/${workspaceId}`);
|
||||
}
|
||||
|
||||
navigate(`${redirect}`);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user