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:
Kilu.He 2024-05-29 10:17:50 +08:00 committed by GitHub
parent cb44a885a1
commit b8b7a10b33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
113 changed files with 2139 additions and 2168 deletions

View File

@ -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>

View File

@ -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

View 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.');

View File

@ -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',
}

View File

@ -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);
};

View File

@ -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;
};

View File

@ -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]
);
};

View File

@ -0,0 +1,8 @@
export enum CoverType {
NormalColor = 'color',
GradientColor = 'gradient',
BuildInImage = 'none',
CustomImage = 'custom',
LocalImage = 'local',
UpsplashImage = 'unsplash',
}

View File

@ -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]);

View File

@ -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);
}
}

View File

@ -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() || {};

View File

@ -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);
}
}

View File

@ -51,6 +51,7 @@ export interface DatabaseService {
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
}>;
closeDatabase: (databaseId: string) => Promise<void>;
}
export interface UserService {

View File

@ -7,6 +7,10 @@ export class TauriDatabaseService implements DatabaseService {
//
}
async closeDatabase(_databaseId: string) {
return Promise.reject('Not implemented');
}
async openDatabase(
_workspaceId: string,
_viewId: string

View File

@ -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;

View File

@ -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);
}

View File

@ -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 {

View File

@ -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>
);
};

View File

@ -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

View File

@ -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,
};
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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`}
/>
)}
>

View File

@ -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]);

View File

@ -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>

View File

@ -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>;

View File

@ -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({

View File

@ -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]);
}

View File

@ -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,
};
}

View File

@ -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>

View File

@ -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}
>

View File

@ -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>
);

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -1 +1,3 @@
export * from './Board';
import { lazy } from 'react';
export const Board = lazy(() => import('./Board'));

View File

@ -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 {

View File

@ -1 +1,3 @@
export * from './Calendar';
import { lazy } from 'react';
export const Calendar = lazy(() => import('./Calendar'));

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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} />;
})}

View File

@ -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;

View File

@ -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)}
>

View File

@ -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 />

View File

@ -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'} />}

View File

@ -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'}>

View File

@ -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));
};

View File

@ -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'}>

View File

@ -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;

View File

@ -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>
);

View File

@ -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} />;
}

View File

@ -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} />
)}

View File

@ -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>;
}

View File

@ -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}

View File

@ -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');
}
}}

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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,
});
}

View File

@ -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>
);
}

View File

@ -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]);

View File

@ -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} />;
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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);

View File

@ -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>
);
}

View File

@ -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'}>

View File

@ -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} />

View File

@ -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;

View File

@ -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>

View File

@ -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) {

View File

@ -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);
}

View File

@ -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} />

View File

@ -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>
);
};

View File

@ -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 }) => {

View File

@ -1,7 +0,0 @@
import React from 'react';
function BoardBlock() {
return <div></div>;
}
export default BoardBlock;

View File

@ -1,7 +0,0 @@
import React from 'react';
function CalendarBlock() {
return <div></div>;
}
export default CalendarBlock;

View File

@ -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

View File

@ -1,7 +0,0 @@
import React from 'react';
function GridBlock() {
return <div></div>;
}
export default GridBlock;

View File

@ -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 '';
}

View File

@ -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'));

View File

@ -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}

View File

@ -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}>

View File

@ -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}

View File

@ -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>
);
};

View File

@ -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}

View File

@ -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;
}
}

View File

@ -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>
);
}

View File

@ -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}
/>

View File

@ -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'}

View File

@ -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 };
}

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1 @@
export * from './Breadcrumb';

View File

@ -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;

View File

@ -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