Compare commits

...
Author SHA1 Message Date
qinluhe 9b4b8a7f24 chore: modifed server.js to server.ts 2024-07-04 12:59:33 +08:00
qinluhe a7397b74a0 chore: modified server.cjs to server.js 2024-07-04 12:37:42 +08:00
qinluhe 6e4cc3a174 chore: modified Dockerfile 2024-07-04 12:23:10 +08:00
qinluhe e9ce211c16 chore: update deploy.sh 2024-07-04 12:05:10 +08:00
qinluhe 59bbc43fc4 chore: move the files and modified the en 2024-07-03 22:54:39 +08:00
qinluhe 1534a02953 fix: title longer 2024-07-03 17:26:01 +08:00
Zack 55f06cb7a9 chore: make bash script more portable (#5679) 2024-07-03 15:26:28 +08:00
qinluhe e21ad18453 fix: text color 2024-07-03 15:21:50 +08:00
qinluhe 1b69f7841c fix: save the dark mode in local storage 2024-07-03 14:56:43 +08:00
qinluhe b2d6876347 fix: the dark mode color 2024-07-03 14:52:18 +08:00
qinluhe d5c08a3c80 fix: modified bullted icon style 2024-07-03 13:45:29 +08:00
Lucas.Xu f96218820d feat: disable publish in non-apppflowy-cloud user mode 2024-07-03 13:43:59 +08:00
Lucas.Xu bec7227cff chore: use beta.appflowy.com 2024-07-03 12:00:47 +08:00
Lucas.Xu 4ece2ff959 feat: show unpublish error 2024-07-03 11:05:15 +08:00
Lucas.Xu 59b36f67c8 feat: improve confirm deletion dialog 2024-07-03 10:47:59 +08:00
Lucas.Xu 38f49d617f Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-07-03 10:21:20 +08:00
Lucas.Xu 5be41553be feat: unpublish the page auto when moving it to another space 2024-07-03 10:21:17 +08:00
Kilu 2a74fbcfe6 fix: to 404
fix: to 404

fix: to 404

fix: the error to 404
2024-07-02 23:10:30 +08:00
Lucas.Xu 81f0100e78 feat: add copy toast in publish tab 2024-07-02 22:15:12 +08:00
Lucas.Xu cb2a7c9f98 feat: show a confirm deletion dialog if the deleted page contains published page 2024-07-02 21:40:52 +08:00
Lucas.Xu 2da3744700 chore: update copy 2024-07-02 17:10:55 +08:00
Lucas.Xu 0daabd2f3c feat: use default publish name 2024-07-02 17:09:19 +08:00
Lucas.Xu be4d60ca0d Merge branch 'main' into feat/support-get-encoded-collab-event 2024-07-02 16:42:00 +08:00
qinluhe e852d25ad8 chore: add a params 2024-07-02 15:39:45 +08:00
Kilu 9c68983ff3 fix: support copy button 2024-07-02 12:11:47 +08:00
Kilu df51cfda09 fix: support copy button 2024-07-02 10:55:09 +08:00
Kilu 0a40e67e4f fix: support copy button 2024-07-02 10:46:26 +08:00
Kilu 2bf83df2b3 fix: og image 2024-07-01 12:53:25 +08:00
Kilu 03a068eb0b fix: deploy 2024-06-30 15:52:13 +08:00
Kilu 4348355868 fix: deploy 2024-06-30 15:47:30 +08:00
Kilu 15de98d61c fix: deploy 2024-06-30 15:46:22 +08:00
Kilu 80255f98de fix: deploy 2024-06-30 15:45:01 +08:00
Kilu 9595011f4e fix: deploy 2024-06-30 15:36:52 +08:00
Kilu 357988c248 fix: deploy 2024-06-30 15:34:38 +08:00
Kilu 655b0735bb fix: deploy 2024-06-30 15:31:16 +08:00
Kilu 28e3025c83 fix: rerelease 2024-06-30 01:06:54 +08:00
Kilu 460b9968e6 fix: bugs 2024-06-30 00:30:06 +08:00
Kilu 4e43ee1904 fix: bugs 2024-06-29 23:43:57 +08:00
Kilu 4ae9aac50e fix: bugs 2024-06-29 23:23:02 +08:00
Kilu b9b7ce60ce fix: bugs 2024-06-29 22:54:03 +08:00
Kilu 5396f07ad2 fix: bugs 2024-06-29 22:18:47 +08:00
Kilu a5193477bb fix: bugs 2024-06-29 20:50:27 +08:00
Kilu b59bda9210 fix: bugs 2024-06-29 20:28:19 +08:00
Kilu 3aabe7182d fix: bugs 2024-06-29 20:16:49 +08:00
Kilu 2b602f10cb fix: bugs 2024-06-29 20:13:48 +08:00
Kilu de3a1dfd15 fix: bugs 2024-06-29 20:08:47 +08:00
Kilu deae07b83e fix: bugs 2024-06-29 20:05:50 +08:00
Kilu a5a8e8f125 fix: bugs 2024-06-29 20:02:56 +08:00
Kilu e5fd455e93 fix: bugs 2024-06-29 17:23:18 +08:00
Kilu d4c5180761 fix: bugs 2024-06-29 16:29:54 +08:00
Kilu 7b133e60fc fix: bugs 2024-06-29 16:23:12 +08:00
Kilu 215910cd24 fix: bugs 2024-06-29 16:18:48 +08:00
Kilu e211fdce13 fix: deploy 2024-06-29 16:18:48 +08:00
Kilu b1afc455e9 fix: deploy 2024-06-29 16:18:48 +08:00
Kilu df7e70e9f6 fix: deploy 2024-06-29 16:18:48 +08:00
Kilu 6e8b0aa467 fix: deploy 2024-06-29 16:18:48 +08:00
Kilu 8bd1df539f fix: deploy 2024-06-29 16:18:48 +08:00
Kilu 1c0992ed58 fix: deploy 2024-06-29 16:18:47 +08:00
Kilu f7174727d4 fix: deploy 2024-06-29 16:18:47 +08:00
Kilu 91368a04b5 fix: deploy 2024-06-29 16:18:47 +08:00
Kilu 967a647342 fix: deploy 2024-06-29 16:18:47 +08:00
Kilu 9ab1ed8ccb fix: deploy 2024-06-29 16:18:47 +08:00
Kilu 30e24294cb fix: deploy 2024-06-29 16:18:47 +08:00
Kilu 7d55369dd8 fix: deploy 2024-06-29 16:18:47 +08:00
Kilu 37864a07ce fix: some style 2024-06-29 16:18:47 +08:00
Lucas.Xu 6cb81d7b7c Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-28 14:03:26 +08:00
Lucas.Xu 14d62db688 Merge branch 'main' into feat/support-get-encoded-collab-event 2024-06-28 14:03:20 +08:00
Kilu 70e6a5c545 fix: some style 2024-06-28 14:01:41 +08:00
Lucas.Xu 724f1ce362 fix: rust ci 2024-06-28 13:35:03 +08:00
Lucas.Xu a379e805bc Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-28 13:33:43 +08:00
Lucas.Xu 6033e128f0 chore: refactor share menu colors 2024-06-28 13:33:39 +08:00
Kilu f83bf109fc fix: some style 2024-06-28 13:17:54 +08:00
Lucas.Xu eab1519c3e // Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-28 13:17:16 +08:00
Lucas.Xu 7dd4a52c61 feat: refacotor export-as 2024-06-28 13:17:13 +08:00
Kilu 07f22a9913 fix: some style 2024-06-28 12:30:22 +08:00
Kilu c398c0cb19 fix: lint 2024-06-28 12:04:06 +08:00
Kilu c07158935d fix: some style 2024-06-28 11:53:47 +08:00
Lucas.Xu 2eeac030a5 Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-28 11:51:10 +08:00
Lucas.Xu 95c4da5e6f feat: refacotor share menu UI 2024-06-28 11:51:07 +08:00
Kilu 40f7e8ed5a fix: some style 2024-06-28 11:18:53 +08:00
Kilu 349705768a fix: some style 2024-06-28 10:54:25 +08:00
Lucas.Xu 701b55dc45 chore: change color 2024-06-28 10:34:52 +08:00
Lucas.Xu b4fe7fa03d Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-28 10:24:08 +08:00
Lucas.Xu 2f9f2c1ba7 fix: the name is too long to publish 2024-06-28 10:24:03 +08:00
Kilu 15dfd194a1 fix: some style 2024-06-27 20:12:31 +08:00
Kilu 8c0d451630 fix: some style 2024-06-27 20:11:13 +08:00
Kilu 516cf1c9e5 fix: some style 2024-06-27 20:07:47 +08:00
Kilu 2cbcf2968c fix: some style 2024-06-27 19:57:29 +08:00
Kilu 163a80a8ee fix: some style 2024-06-27 19:54:13 +08:00
Kilu 127d0319ad fix: some style 2024-06-27 19:46:35 +08:00
Lucas.Xu e51b0f90de Merge branch 'main' into feat/support-get-encoded-collab-event 2024-06-27 17:57:56 +08:00
Lucas.Xu b67a576b19 Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-27 17:57:36 +08:00
Lucas.Xu 39f2789e13 chore: refactor publish bloc 2024-06-27 17:57:33 +08:00
qinluhe c9b1591dba fix: update codes 2024-06-27 14:59:57 +08:00
qinluhe 0ffc4cb4eb fix: update codes 2024-06-27 14:26:36 +08:00
qinluhe 3b76ee9078 fix: update codes 2024-06-27 14:03:53 +08:00
qinluhe e27dddef61 fix: update codes 2024-06-27 14:00:37 +08:00
qinluhe 3f05ac8f62 fix: update codes 2024-06-27 13:49:09 +08:00
Kilu a5cb427a8a fix: some style 2024-06-26 21:23:40 +08:00
Kilu dc910e46b2 fix: some style 2024-06-26 20:26:19 +08:00
Kilu 303e2b30ac fix: some style 2024-06-26 19:47:24 +08:00
Kilu 492b9fddb5 fix: some style 2024-06-26 19:08:10 +08:00
Kilu 4cef37d9a7 fix: some style 2024-06-26 19:04:17 +08:00
Kilu 990c149c75 fix: some style 2024-06-26 17:57:33 +08:00
Kilu b64514c7e4 fix: some style 2024-06-26 17:47:34 +08:00
Kilu 971c0e1ea9 fix: some style 2024-06-26 17:47:25 +08:00
Kilu 2557d90c0d fix: some style 2024-06-26 17:05:10 +08:00
Kilu 431e03e3c1 fix: modified web server 2024-06-26 17:05:10 +08:00
Kilu 1c90d935be fix: lint error 2024-06-26 17:05:09 +08:00
Kilu 1309a94439 feat: support publish interfaces 2024-06-26 17:05:07 +08:00
Lucas.Xu 02199e3a73 feat: fetch the publish info to show the publish status 2024-06-26 17:03:25 +08:00
Lucas.Xu 9119e4a926 feat: integrate unpublish api 2024-06-26 17:03:25 +08:00
Lucas.Xu 14ba35c728 feat: integrate publish api 2024-06-26 17:03:25 +08:00
qinluhe e88423c83e feat: support publish view and unpublish views 2024-06-26 17:03:22 +08:00
Lucas.Xu 95194930f9 feat: call the publish event 2024-06-26 17:01:10 +08:00
Lucas.Xu 360f3df99e chore: refacotor share bloc 2024-06-26 17:01:10 +08:00
Lucas.Xu 3edcc08543 feat: publish page to the web 2024-06-26 17:01:10 +08:00
qinluhe 1c8913eb79 feat: support publish view and unpublish views 2024-06-26 17:00:52 +08:00
qinluhe 4151c48180 feat: support a event for getting encoded collab of document 2024-06-26 16:59:23 +08:00
307 changed files with 6491 additions and 4582 deletions

No files matched your search

-72
View File
@@ -1,72 +0,0 @@
name: Deploy Web (Test)
on:
push:
branches:
- build/test
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
env:
SSH_PRIVATE_KEY: ${{ secrets.WEB_TEST_SSH_PRIVATE_KEY }}
REMOTE_HOST: ${{ secrets.WEB_TEST_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.WEB_TEST_REMOTE_USER }}
SSL_CERTIFICATE: ${{ secrets.WEB_TEST_SSL_CERTIFICATE }}
SSL_CERTIFICATE_KEY: ${{ secrets.WEB_TEST_SSL_CERTIFICATE_KEY }}
ENV_FILE: test.env
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
- name: setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Node_modules cache
uses: actions/cache@v2
with:
path: frontend/appflowy_web_app/node_modules
key: node-modules-${{ runner.os }}
- name: install frontend dependencies
working-directory: frontend/appflowy_web_app
run: |
pnpm install
- name: copy env file
working-directory: frontend/appflowy_web_app
run: |
cp ${{ env.ENV_FILE }} .env
- name: test and lint
working-directory: frontend/appflowy_web_app
run: |
pnpm run lint
- name: build
working-directory: frontend/appflowy_web_app
run: |
pnpm run build
- name: generate SSL certificate
run: |
echo "${{ env.SSL_CERTIFICATE }}" > nginx-signed.crt
echo "${{ env.SSL_CERTIFICATE_KEY }}" > nginx-signed.key
- name: Deploy to EC2
uses: easingthemes/ssh-deploy@main
with:
SSH_PRIVATE_KEY: ${{ env.SSH_PRIVATE_KEY }}
ARGS: "-rlgoDzvc -i"
SOURCE: "frontend/appflowy_web_app/dist frontend/appflowy_web_app/server.cjs frontend/appflowy_web_app/start.sh frontend/appflowy_web_app/Dockerfile frontend/appflowy_web_app/nginx.conf frontend/appflowy_web_app/.env nginx-signed.crt nginx-signed.key"
REMOTE_HOST: ${{ env.REMOTE_HOST }}
REMOTE_USER: ${{ env.REMOTE_USER }}
EXCLUDE: "frontend/appflowy_web_app/dist/, frontend/appflowy_web_app/node_modules/"
SCRIPT_AFTER: |
docker build -t appflowy-web-app .
docker rm -f appflowy-web-app || true
docker run -d -p 80:80 -p 443:443 --name appflowy-web-app appflowy-web-app
+102
View File
@@ -0,0 +1,102 @@
name: Deploy
on:
workflow_dispatch:
inputs:
environment:
description: 'Environment'
required: true
default: 'test'
port:
description: 'Docker Port'
required: true
default: '30012'
api_base_url:
description: 'API Base URL'
required: true
default: 'https://test.appflowy.cloud'
api_gotrue_url:
description: 'GoTrue API URL'
required: true
default: 'https://test.appflowy.cloud/gotrue'
ws_url:
description: 'Websocket URL'
required: true
default: 'wss://test.appflowy.cloud/ws/v1'
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
- name: setup pnpm
uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}
- name: Node_modules cache
uses: actions/cache@v2
with:
path: frontend/appflowy_web_app/node_modules
key: node-modules-${{ runner.os }}
- name: install frontend dependencies
working-directory: frontend/appflowy_web_app
run: |
pnpm install
- name: generate env file
working-directory: frontend/appflowy_web_app
run: |
echo "AF_BASE_URL=${{ github.event.inputs.api_base_url }}" >> .env
echo "AF_GOTRUE_URL=${{ github.event.inputs.api_gotrue_url }}" >> .env
echo "AF_WS_URL=${{ github.event.inputs.ws_url }}" >> .env
- name: test and lint
working-directory: frontend/appflowy_web_app
run: |
pnpm run lint
- name: build
working-directory: frontend/appflowy_web_app
run: |
pnpm run build
- name: Archive build output
working-directory: frontend/appflowy_web_app
run: |
tar -czf build-output.tar.gz dist deploy .env
- name: Deploy to EC2 (Test)
if: github.event.inputs.environment == 'test'
uses: easingthemes/ssh-deploy@main
with:
SSH_PRIVATE_KEY: ${{ secrets.WEB_TEST_SSH_PRIVATE_KEY }}
ARGS: "-rlgoDzvc -i"
SOURCE: frontend/appflowy_web_app/build-output.tar.gz frontend/appflowy_web_app/deploy/deploy.sh
TARGET: /home/${{ secrets.WEB_TEST_REMOTE_USER }}/appflowy-web-app
REMOTE_HOST: ${{ secrets.WEB_TEST_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.WEB_TEST_REMOTE_USER }}
SCRIPT_AFTER: |
cd appflowy-web-app
chmod +x deploy.sh
sh deploy.sh ${{ github.event.inputs.port }}
- name: Deploy to EC2 (Prod)
if: github.event.inputs.environment == 'prod'
uses: easingthemes/ssh-deploy@main
with:
SSH_PRIVATE_KEY: ${{ secrets.WEB_PROD_SSH_PRIVATE_KEY }}
ARGS: "-rlgoDzvc -i"
TARGET: /home/${{ secrets.WEB_PROD_REMOTE_USER }}/appflowy-web-app
SOURCE: frontend/appflowy_web_app/build-output.tar.gz frontend/appflowy_web_app/deploy/deploy.sh
REMOTE_HOST: ${{ secrets.WEB_PROD_REMOTE_HOST }}
REMOTE_USER: ${{ secrets.WEB_PROD_REMOTE_USER }}
SCRIPT_AFTER: |
cd appflowy-web-app
chmod +x deploy.sh
sh deploy.sh ${{ github.event.inputs.port }}
+4 -4
View File
@@ -116,11 +116,11 @@
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// "request": "attach",
// "pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
"request": "launch",
"program": "/Users/lucas.xu/Desktop/development/AppFlowy/frontend/appflowy_flutter/build/macos/Build/Products/Debug/AppFlowy.app",
},
{
// https://tauri.app/v1/guides/debugging/vs-code
@@ -1,58 +1,177 @@
import 'dart:io';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/export/document_exporter.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'document_share_bloc.freezed.dart';
// todo: replace with beta
const _url = 'https://beta.appflowy.com';
class DocumentShareBloc extends Bloc<DocumentShareEvent, DocumentShareState> {
DocumentShareBloc({
required this.view,
}) : super(const DocumentShareState.initial()) {
}) : super(DocumentShareState.initial()) {
on<DocumentShareEvent>((event, emit) async {
await event.when(
initial: () async {
viewListener = ViewListener(viewId: view.id)
..start(
onViewUpdated: (value) {
add(DocumentShareEvent.updateViewName(value.name));
},
);
final publishInfo = await ViewBackendService.getPublishInfo(view);
final enablePublish =
await UserBackendService.getCurrentUserProfile().fold(
(v) => v.authenticator == AuthenticatorPB.AppFlowyCloud,
(p) => false,
);
publishInfo.fold((s) {
emit(
state.copyWith(
isPublished: true,
url: '$_url/${s.namespace}/${s.publishName}',
viewName: view.name,
enablePublish: enablePublish,
),
);
}, (f) {
emit(
state.copyWith(
isPublished: false,
url: '',
viewName: view.name,
enablePublish: enablePublish,
),
);
});
},
share: (type, path) async {
if (DocumentShareType.unimplemented.contains(type)) {
Log.error('DocumentShareType $type is not implemented');
return;
}
emit(const DocumentShareState.loading());
emit(state.copyWith(isLoading: true));
final exporter = DocumentExporter(view);
final FlowyResult<ExportDataPB, FlowyError> result =
await exporter.export(type.exportType).then((value) {
return value.fold(
(s) {
if (path != null) {
switch (type) {
case DocumentShareType.markdown:
return FlowyResult.success(_saveMarkdownToPath(s, path));
case DocumentShareType.html:
return FlowyResult.success(_saveHTMLToPath(s, path));
default:
break;
}
}
return FlowyResult.failure(FlowyError());
},
(f) => FlowyResult.failure(f),
final result = await _export(type, path);
emit(
state.copyWith(
isLoading: false,
exportResult: result,
),
);
},
publish: (nameSpace, publishName) async {
// set space name
try {
final result =
await ViewBackendService.getPublishNameSpace().getOrThrow();
await ViewBackendService.publish(
view,
name: publishName,
).getOrThrow();
emit(
state.copyWith(
isPublished: true,
publishResult: FlowySuccess(null),
unpublishResult: null,
url: '$_url/${result.namespace}/$publishName',
),
);
} catch (e) {
Log.error('publish error: $e');
emit(
state.copyWith(
isPublished: false,
publishResult: FlowyResult.failure(
FlowyError(msg: 'publish error: $e'),
),
unpublishResult: null,
url: '',
),
);
}
},
unPublish: () async {
emit(
state.copyWith(
publishResult: null,
unpublishResult: null,
),
);
final result = await ViewBackendService.unpublish(view);
final isPublished = !result.isSuccess;
result.onFailure((f) {
Log.error('unpublish error: $f');
});
emit(DocumentShareState.finish(result));
emit(
state.copyWith(
isPublished: isPublished,
publishResult: null,
unpublishResult: result,
url: result.fold((_) => '', (_) => state.url),
),
);
},
updateViewName: (viewName) async {
emit(state.copyWith(viewName: viewName));
},
);
});
}
final ViewPB view;
late final ViewListener viewListener;
late final exporter = DocumentExporter(view);
@override
Future<void> close() async {
await viewListener.stop();
return super.close();
}
Future<FlowyResult<ExportDataPB, FlowyError>> _export(
DocumentShareType type,
String? path,
) async {
final result = await exporter.export(type.exportType);
return result.fold(
(s) {
if (path != null) {
switch (type) {
case DocumentShareType.markdown:
return FlowySuccess(_saveMarkdownToPath(s, path));
case DocumentShareType.html:
return FlowySuccess(_saveHTMLToPath(s, path));
default:
break;
}
}
return FlowyResult.failure(FlowyError());
},
(f) => FlowyResult.failure(f),
);
}
ExportDataPB _saveMarkdownToPath(String markdown, String path) {
File(path).writeAsStringSync(markdown);
@@ -93,15 +212,38 @@ enum DocumentShareType {
@freezed
class DocumentShareEvent with _$DocumentShareEvent {
const factory DocumentShareEvent.share(DocumentShareType type, String? path) =
Share;
const factory DocumentShareEvent.initial() = _Initial;
const factory DocumentShareEvent.share(
DocumentShareType type,
String? path,
) = _Share;
const factory DocumentShareEvent.publish(
String nameSpace,
String pageId,
) = _Publish;
const factory DocumentShareEvent.unPublish() = _UnPublish;
const factory DocumentShareEvent.updateViewName(String name) =
_UpdateViewName;
}
@freezed
class DocumentShareState with _$DocumentShareState {
const factory DocumentShareState.initial() = _Initial;
const factory DocumentShareState.loading() = _Loading;
const factory DocumentShareState.finish(
FlowyResult<ExportDataPB, FlowyError> successOrFail,
) = _Finish;
const factory DocumentShareState({
required bool isPublished,
required bool isLoading,
required String url,
required String viewName,
required bool enablePublish,
FlowyResult<ExportDataPB, FlowyError>? exportResult,
FlowyResult<void, FlowyError>? publishResult,
FlowyResult<void, FlowyError>? unpublishResult,
}) = _DocumentShareState;
factory DocumentShareState.initial() => const DocumentShareState(
isLoading: false,
isPublished: false,
enablePublish: true,
url: '',
viewName: '',
);
}
@@ -0,0 +1,130 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/export/document_exporter.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ExportTab extends StatelessWidget {
const ExportTab({
super.key,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
const VSpace(10),
_ExportButton(
title: LocaleKeys.shareAction_html.tr(),
svg: FlowySvgs.export_html_s,
onTap: () => _exportHTML(context),
),
const VSpace(10),
_ExportButton(
title: LocaleKeys.shareAction_markdown.tr(),
svg: FlowySvgs.export_markdown_s,
onTap: () => _exportMarkdown(context),
),
const VSpace(10),
_ExportButton(
title: LocaleKeys.shareAction_clipboard.tr(),
svg: FlowySvgs.duplicate_s,
onTap: () => _exportToClipboard(context),
),
],
);
}
Future<void> _exportHTML(BuildContext context) async {
final viewName = context.read<DocumentShareBloc>().state.viewName;
final exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '',
fileName: '${viewName.toFileName()}.html',
);
if (context.mounted && exportPath != null) {
context.read<DocumentShareBloc>().add(
DocumentShareEvent.share(
DocumentShareType.html,
exportPath,
),
);
}
}
Future<void> _exportMarkdown(BuildContext context) async {
final viewName = context.read<DocumentShareBloc>().state.viewName;
final exportPath = await getIt<FilePickerService>().saveFile(
dialogTitle: '',
fileName: '${viewName.toFileName()}.md',
);
if (context.mounted && exportPath != null) {
context.read<DocumentShareBloc>().add(
DocumentShareEvent.share(
DocumentShareType.markdown,
exportPath,
),
);
}
}
Future<void> _exportToClipboard(BuildContext context) async {
final documentExporter =
DocumentExporter(context.read<DocumentShareBloc>().view);
final result = await documentExporter.export(DocumentExportType.markdown);
result.fold(
(markdown) {
getIt<ClipboardService>().setData(
ClipboardServiceData(plainText: markdown),
);
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
);
},
(error) => showToastNotification(context, message: error.msg),
);
}
}
class _ExportButton extends StatelessWidget {
const _ExportButton({
required this.title,
required this.svg,
required this.onTap,
});
final String title;
final FlowySvgData svg;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).isLightMode
? const Color(0x1E14171B)
: Colors.white.withOpacity(0.1);
final radius = BorderRadius.circular(10.0);
return FlowyButton(
margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
iconPadding: 12,
decoration: BoxDecoration(
border: Border.all(
color: color,
),
borderRadius: radius,
),
radius: radius,
text: FlowyText(title),
leftIcon: FlowySvg(svg),
onTap: onTap,
);
}
}
@@ -0,0 +1,11 @@
import 'package:appflowy/util/theme_extension.dart';
import 'package:flutter/material.dart';
class ShareMenuColors {
static Color borderColor(BuildContext context) {
final borderColor = Theme.of(context).isLightMode
? const Color(0x1E14171B)
: Colors.white.withOpacity(0.1);
return borderColor;
}
}
@@ -0,0 +1,19 @@
String replaceInvalidChars(String input) {
final RegExp invalidCharsRegex = RegExp('[^a-zA-Z0-9-]');
return input.replaceAll(invalidCharsRegex, '-');
}
Future<String> generateNameSpace() async {
return '';
}
// The backend limits the publish name to a maximum of 120 characters.
// If the combined length of the ID and the name exceeds 120 characters,
// we will truncate the name to ensure the final result is within the limit.
// The name should only contain alphanumeric characters and hyphens.
Future<String> generatePublishName(String id, String name) async {
if (name.length >= 120 - id.length) {
name = name.substring(0, 120 - id.length);
}
return replaceInvalidChars('$name-$id');
}
@@ -0,0 +1,294 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/share/pubish_color_extension.dart';
import 'package:appflowy/plugins/document/presentation/share/publish_name_generator.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class PublishTab extends StatelessWidget {
const PublishTab({super.key});
@override
Widget build(BuildContext context) {
return BlocConsumer<DocumentShareBloc, DocumentShareState>(
listener: (context, state) {
_showToast(context, state);
},
builder: (context, state) {
return state.isPublished
? _PublishedWidget(
url: state.url,
onVisitSite: () {},
onUnPublish: () {
context
.read<DocumentShareBloc>()
.add(const DocumentShareEvent.unPublish());
},
)
: _UnPublishWidget(
onPublish: () async {
final id = context.read<DocumentShareBloc>().view.id;
final publishName = await generatePublishName(
id,
state.viewName,
);
if (context.mounted) {
context.read<DocumentShareBloc>().add(
DocumentShareEvent.publish('', publishName),
);
}
},
);
},
);
}
void _showToast(BuildContext context, DocumentShareState state) {
if (state.publishResult != null) {
state.publishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
),
);
} else if (state.unpublishResult != null) {
state.unpublishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg,
),
);
}
}
}
class _PublishedWidget extends StatefulWidget {
const _PublishedWidget({
required this.url,
required this.onVisitSite,
required this.onUnPublish,
});
final String url;
final VoidCallback onVisitSite;
final VoidCallback onUnPublish;
@override
State<_PublishedWidget> createState() => _PublishedWidgetState();
}
class _PublishedWidgetState extends State<_PublishedWidget> {
final controller = TextEditingController();
@override
void initState() {
super.initState();
controller.text = widget.url;
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(16),
const _PublishTabHeader(),
const VSpace(16),
_PublishUrl(
controller: controller,
onCopy: (url) {
getIt<ClipboardService>().setData(
ClipboardServiceData(plainText: url),
);
showToastNotification(
context,
message: LocaleKeys.grid_url_copy.tr(),
);
},
onSubmitted: (url) {},
),
const VSpace(16),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildButton(
context,
name: LocaleKeys.shareAction_unPublish.tr(),
borderColor: ShareMenuColors.borderColor(context),
onTap: widget.onUnPublish,
),
const Spacer(),
_buildButton(
context,
name: LocaleKeys.shareAction_visitSite.tr(),
backgroundColor: Theme.of(context).colorScheme.primary,
textColor:
Theme.of(context).isLightMode ? Colors.white : Colors.black,
onTap: () {
safeLaunchUrl(controller.text);
},
),
],
),
],
);
}
Widget _buildButton(
BuildContext context, {
required String name,
Color? backgroundColor,
Color borderColor = Colors.transparent,
Color? textColor,
required VoidCallback onTap,
}) {
return SizedBox(
height: 36,
width: 184,
child: FlowyButton(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: borderColor),
),
radius: BorderRadius.circular(10),
text: FlowyText.regular(
name,
textAlign: TextAlign.center,
color: textColor,
),
onTap: onTap,
),
);
}
}
class _UnPublishWidget extends StatelessWidget {
const _UnPublishWidget({
required this.onPublish,
});
final VoidCallback onPublish;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(16),
const _PublishTabHeader(),
const VSpace(16),
RoundedTextButton(
height: 36,
title: LocaleKeys.shareAction_publish.tr(),
padding: const EdgeInsets.symmetric(vertical: 9.0),
fontSize: 14.0,
textColor: Theme.of(context).colorScheme.onPrimary,
onPressed: onPublish,
),
],
);
}
}
class _PublishTabHeader extends StatelessWidget {
const _PublishTabHeader();
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const FlowySvg(FlowySvgs.share_publish_s),
const HSpace(6),
FlowyText(LocaleKeys.shareAction_publishToTheWeb.tr()),
],
),
const VSpace(4),
FlowyText.regular(
LocaleKeys.shareAction_publishToTheWebHint.tr(),
fontSize: 12,
maxLines: 3,
color: Theme.of(context).hintColor,
),
],
);
}
}
class _PublishUrl extends StatelessWidget {
const _PublishUrl({
required this.controller,
required this.onCopy,
required this.onSubmitted,
});
final TextEditingController controller;
final void Function(String url) onCopy;
final void Function(String url) onSubmitted;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 36,
child: FlowyTextField(
readOnly: true,
autoFocus: false,
controller: controller,
enableBorderColor: ShareMenuColors.borderColor(context),
suffixIcon: _buildCopyLinkIcon(context),
),
);
}
Widget _buildCopyLinkIcon(BuildContext context) {
return FlowyHover(
child: GestureDetector(
onTap: () => onCopy(controller.text),
child: Container(
width: 36,
height: 36,
alignment: Alignment.center,
padding: const EdgeInsets.all(10),
decoration: const BoxDecoration(
border: Border(left: BorderSide(color: Color(0x141F2329))),
),
child: const FlowySvg(
FlowySvgs.m_toolbar_link_m,
),
),
),
);
}
}
@@ -1,6 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_share_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/share/share_menu.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/export/document_exporter.dart';
@@ -13,6 +14,7 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -28,23 +30,53 @@ class DocumentShareButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<DocumentShareBloc>(param1: view),
create: (context) => getIt<DocumentShareBloc>(param1: view)
..add(const DocumentShareEvent.initial()),
child: BlocListener<DocumentShareBloc, DocumentShareState>(
listener: (context, state) {
state.mapOrNull(
finish: (state) {
state.successOrFail.fold(
(data) => _handleExportData(context, data),
_handleExportError,
);
},
);
if (state.isLoading == false && state.exportResult != null) {
state.exportResult!.fold(
(data) => _handleExportData(context, data),
_handleExportError,
);
}
},
child: BlocBuilder<DocumentShareBloc, DocumentShareState>(
builder: (context, state) => SizedBox(
height: 32.0,
child: IntrinsicWidth(child: ShareActionList(view: view)),
),
builder: (context, state) {
final tabs = [
if (state.enablePublish) ShareMenuTab.publish,
ShareMenuTab.exportAs,
];
final shareBloc = context.read<DocumentShareBloc>();
return SizedBox(
height: 32.0,
child: IntrinsicWidth(
child: AppFlowyPopover(
direction: PopoverDirection.bottomWithRightAligned,
constraints: const BoxConstraints(
maxWidth: 422,
),
offset: const Offset(0, 8),
popupBuilder: (context) => BlocProvider.value(
value: shareBloc,
child: ShareMenu(
tabs: tabs,
),
),
child: RoundedTextButton(
title: LocaleKeys.shareAction_buttonText.tr(),
padding: const EdgeInsets.symmetric(horizontal: 14.0),
fontSize: 14.0,
fontWeight: FontWeight.w500,
borderRadius: const BorderRadius.all(
Radius.circular(10.0),
),
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
),
);
},
),
),
);
@@ -0,0 +1,147 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/tab/_round_underline_tab_indicator.dart';
import 'package:appflowy/plugins/document/presentation/share/export_tab.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'publish_tab.dart';
enum ShareMenuTab {
share,
publish,
exportAs;
String get i18n {
switch (this) {
case ShareMenuTab.share:
return LocaleKeys.shareAction_shareTab.tr();
case ShareMenuTab.publish:
return LocaleKeys.shareAction_publishTab.tr();
case ShareMenuTab.exportAs:
return LocaleKeys.shareAction_exportAsTab.tr();
}
}
}
class ShareMenu extends StatefulWidget {
const ShareMenu({
super.key,
required this.tabs,
});
final List<ShareMenuTab> tabs;
@override
State<ShareMenu> createState() => _ShareMenuState();
}
class _ShareMenuState extends State<ShareMenu>
with SingleTickerProviderStateMixin {
late ShareMenuTab selectedTab = widget.tabs.first;
late final tabController = TabController(
length: widget.tabs.length,
vsync: this,
initialIndex: widget.tabs.indexOf(selectedTab),
);
@override
Widget build(BuildContext context) {
if (widget.tabs.isEmpty) {
return const SizedBox.shrink();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const VSpace(10),
Container(
alignment: Alignment.centerLeft,
height: 30,
child: _buildTabBar(context),
),
Divider(
color: Theme.of(context).dividerColor,
height: 1,
thickness: 1,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14.0),
child: _buildTab(context),
),
const VSpace(20),
],
);
}
@override
void dispose() {
tabController.dispose();
super.dispose();
}
Widget _buildTabBar(BuildContext context) {
final children = [
for (final tab in widget.tabs)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _Segment(
title: tab.i18n.tr(),
isSelected: selectedTab == tab,
),
),
];
return TabBar(
indicatorSize: TabBarIndicatorSize.label,
indicator: RoundUnderlineTabIndicator(
width: 48.0,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 3,
),
insets: const EdgeInsets.only(bottom: -2),
),
isScrollable: true,
controller: tabController,
tabs: children,
onTap: (index) {
setState(() {
selectedTab = widget.tabs[index];
});
},
);
}
Widget _buildTab(BuildContext context) {
switch (selectedTab) {
case ShareMenuTab.publish:
return const PublishTab();
case ShareMenuTab.exportAs:
return const ExportTab();
default:
return const Center(
child: FlowyText('🏡 under construction'),
);
}
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.title,
required this.isSelected,
});
final String title;
final bool isSelected;
@override
Widget build(BuildContext context) {
final textColor = isSelected ? null : Theme.of(context).hintColor;
return FlowyText(
title,
textAlign: TextAlign.center,
color: textColor,
);
}
}
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy/core/config/kv.dart';
@@ -139,10 +140,18 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
},
delete: (e) async {
final result = await ViewBackendService.delete(viewId: view.id);
result.onSuccess((_) {
add(const ViewEvent.unpublish(sync: false));
});
emit(
result.fold(
(l) =>
state.copyWith(successOrFailure: FlowyResult.success(null)),
(l) {
return state.copyWith(
successOrFailure: FlowyResult.success(null),
);
},
(error) => state.copyWith(
successOrFailure: FlowyResult.failure(error),
),
@@ -180,8 +189,11 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
);
emit(
result.fold(
(l) =>
state.copyWith(successOrFailure: FlowyResult.success(null)),
(l) {
return state.copyWith(
successOrFailure: FlowyResult.success(null),
);
},
(error) => state.copyWith(
successOrFailure: FlowyResult.failure(error),
),
@@ -236,6 +248,13 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
}
add(const ViewEvent.setIsExpanded(false));
},
unpublish: (value) async {
if (value.sync) {
await _unpublishPage(view);
} else {
unawaited(_unpublishPage(view));
}
},
);
},
);
@@ -383,6 +402,51 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
return null;
}
// unpublish the page and all its child pages
Future<void> _unpublishPage(ViewPB view) async {
final allChildViews = await _getAllChildViews(view);
final views = [view, ...allChildViews];
// unpublish
await Future.wait(
views.map((view) => ViewBackendService.getPublishInfo(view)),
).then((results) {
final viewsToUnpublish = <ViewPB>[];
for (var i = 0; i < results.length; i++) {
final result = results[i];
if (result.isSuccess) {
viewsToUnpublish.add(views[i]);
}
}
return viewsToUnpublish;
}).then((views) async {
await Future.wait(
views.map((view) => ViewBackendService.unpublish(view)),
);
});
}
Future<List<ViewPB>> _getAllChildViews(ViewPB view) async {
final views = <ViewPB>[];
final childViews =
await ViewBackendService.getChildViews(viewId: view.id).fold(
(s) => s,
(f) => [],
);
for (final child in childViews) {
// filter the view itself
if (child.id == view.id) {
continue;
}
views.add(child);
views.addAll(await _getAllChildViews(child));
}
return views;
}
bool _isSameViewIgnoreChildren(ViewPB from, ViewPB to) {
return _hash(from) == _hash(to);
}
@@ -430,6 +494,8 @@ class ViewEvent with _$ViewEvent {
) = UpdateViewVisibility;
const factory ViewEvent.updateIcon(String? icon) = UpdateIcon;
const factory ViewEvent.collapseAllPages() = CollapseAllPages;
// this event will unpublish the page and all its child pages if they are published
const factory ViewEvent.unpublish({required bool sync}) = Unpublish;
}
@freezed
@@ -291,4 +291,63 @@ class ViewBackendService {
);
return FolderEventUpdateViewVisibilityStatus(payload).send();
}
static Future<FlowyResult<PublishInfoResponsePB, FlowyError>> getPublishInfo(
ViewPB view,
) async {
final payload = ViewIdPB()..value = view.id;
return FolderEventGetPublishInfo(payload).send();
}
static Future<FlowyResult<void, FlowyError>> publish(
ViewPB view, {
String? name,
}) async {
final payload = PublishViewParamsPB()..viewId = view.id;
if (name != null) {
payload.publishName = name;
}
return FolderEventPublishView(payload).send();
}
static Future<FlowyResult<void, FlowyError>> unpublish(
ViewPB view,
) async {
final payload = UnpublishViewsPayloadPB(viewIds: [view.id]);
return FolderEventUnpublishViews(payload).send();
}
static Future<FlowyResult<void, FlowyError>> setPublishNameSpace(
String name,
) async {
final payload = SetPublishNamespacePayloadPB()..newNamespace = name;
return FolderEventSetPublishNamespace(payload).send();
}
static Future<FlowyResult<PublishNamespacePB, FlowyError>>
getPublishNameSpace() async {
return FolderEventGetPublishNamespace().send();
}
static Future<List<ViewPB>> getAllChildViews(ViewPB view) async {
final views = <ViewPB>[];
final childViews =
await ViewBackendService.getChildViews(viewId: view.id).fold(
(s) => s,
(f) => [],
);
for (final child in childViews) {
// filter the view itself
if (child.id == view.id) {
continue;
}
views.add(child);
views.addAll(await getAllChildViews(child));
}
return views;
}
}
@@ -11,6 +11,8 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
typedef MovePageMenuOnSelected = void Function(ViewPB space, ViewPB view);
class MovePageMenu extends StatefulWidget {
const MovePageMenu({
super.key,
@@ -23,7 +25,7 @@ class MovePageMenu extends StatefulWidget {
final ViewPB sourceView;
final UserProfilePB userProfile;
final String workspaceId;
final void Function(ViewPB view) onSelected;
final MovePageMenuOnSelected onSelected;
@override
State<MovePageMenu> createState() => _MovePageMenuState();
@@ -88,7 +90,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
);
}
return Expanded(
child: _buildGroupedViews(state.queryResults!),
child: _buildGroupedViews(space, state.queryResults!),
);
},
),
@@ -99,7 +101,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
);
}
Widget _buildGroupedViews(List<ViewPB> views) {
Widget _buildGroupedViews(ViewPB space, List<ViewPB> views) {
final groupedViews = views
.where(
(view) =>
@@ -108,7 +110,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
.toList();
return _MovePageGroupedViews(
views: groupedViews,
onSelected: widget.onSelected,
onSelected: (view) => widget.onSelected(space, view),
);
}
@@ -124,7 +126,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
child: CurrentSpace(
onTapBlankArea: () {
// move the page to current space
widget.onSelected(space);
widget.onSelected(space, space);
},
space: space,
),
@@ -145,7 +147,7 @@ class _MovePageMenuState extends State<MovePageMenu> {
disableSelectedStatus: true,
// hide the ... and + buttons
rightIconsBuilder: (context, view) => [],
onSelected: (_, view) => widget.onSelected(view),
onSelected: (_, view) => widget.onSelected(space, view),
),
),
),
@@ -19,6 +19,7 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SpacePermissionSwitch extends StatefulWidget {
@@ -222,55 +223,79 @@ class SpaceCancelOrConfirmButton extends StatelessWidget {
}
}
class DeleteSpacePopup extends StatelessWidget {
const DeleteSpacePopup({super.key});
class ConfirmDeletionPopup extends StatefulWidget {
const ConfirmDeletionPopup({
super.key,
required this.title,
required this.description,
required this.onConfirm,
});
final String title;
final String description;
final VoidCallback onConfirm;
@override
State<ConfirmDeletionPopup> createState() => _ConfirmDeletionPopupState();
}
class _ConfirmDeletionPopupState extends State<ConfirmDeletionPopup> {
final focusNode = FocusNode();
@override
Widget build(BuildContext context) {
final space = context.read<SpaceBloc>().state.currentSpace;
final name = space != null ? space.name : '';
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 20.0,
horizontal: 20.0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
FlowyText(
LocaleKeys.space_deleteConfirmation.tr() + name,
fontSize: 14.0,
),
const Spacer(),
FlowyButton(
useIntrinsicWidth: true,
text: const FlowySvg(FlowySvgs.upgrade_close_s),
onTap: () => Navigator.of(context).pop(),
),
],
),
const VSpace(8.0),
FlowyText.regular(
LocaleKeys.space_deleteConfirmationDescription.tr(),
fontSize: 12.0,
color: Theme.of(context).hintColor,
maxLines: 3,
lineHeight: 1.4,
),
const VSpace(20.0),
SpaceCancelOrConfirmButton(
onCancel: () => Navigator.of(context).pop(),
onConfirm: () {
context.read<SpaceBloc>().add(const SpaceEvent.delete(null));
Navigator.of(context).pop();
},
confirmButtonName: LocaleKeys.space_delete.tr(),
confirmButtonColor: Theme.of(context).colorScheme.error,
),
],
return KeyboardListener(
focusNode: focusNode,
autofocus: true,
onKeyEvent: (event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 20.0,
horizontal: 20.0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
FlowyText(
widget.title,
fontSize: 14.0,
),
const Spacer(),
FlowyButton(
useIntrinsicWidth: true,
text: const FlowySvg(FlowySvgs.upgrade_close_s),
onTap: () => Navigator.of(context).pop(),
),
],
),
const VSpace(8.0),
FlowyText.regular(
widget.description,
fontSize: 12.0,
color: Theme.of(context).hintColor,
maxLines: 3,
lineHeight: 1.4,
),
const VSpace(20.0),
SpaceCancelOrConfirmButton(
onCancel: () => Navigator.of(context).pop(),
onConfirm: () {
widget.onConfirm();
Navigator.of(context).pop();
},
confirmButtonName: LocaleKeys.space_delete.tr(),
confirmButtonColor: Theme.of(context).colorScheme.error,
),
],
),
),
);
}
@@ -227,18 +227,14 @@ class _SidebarSpaceHeaderState extends State<SidebarSpaceHeader> {
void _showDeleteSpaceDialog(BuildContext context) {
final spaceBloc = context.read<SpaceBloc>();
showDialog(
final space = spaceBloc.state.currentSpace;
final name = space != null ? space.name : '';
showConfirmDeletionDialog(
context: context,
builder: (_) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: BlocProvider.value(
value: spaceBloc,
child: const SizedBox(width: 440, child: DeleteSpacePopup()),
),
);
name: name,
description: LocaleKeys.space_deleteConfirmationDescription.tr(),
onConfirm: () {
context.read<SpaceBloc>().add(const SpaceEvent.delete(null));
},
);
}
@@ -105,14 +105,16 @@ class _WorkspaceMoreActionWrapper extends CustomActionCell {
case WorkspaceMoreAction.divider:
break;
case WorkspaceMoreAction.delete:
await NavigatorAlertDialog(
title: LocaleKeys.workspace_deleteWorkspaceHintText.tr(),
confirm: () {
await showConfirmDeletionDialog(
context: context,
name: workspace.name,
description: LocaleKeys.workspace_deleteWorkspaceHintText.tr(),
onConfirm: () {
workspaceBloc.add(
UserWorkspaceEvent.deleteWorkspace(workspace.workspaceId),
);
},
).show(context);
);
case WorkspaceMoreAction.rename:
await NavigatorTextFieldDialog(
title: LocaleKeys.workspace_renameWorkspace.tr(),
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/base/icon/icon_picker.dart';
@@ -5,6 +7,7 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/rename_view/rename_view_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
@@ -17,9 +20,10 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_add_button.d
import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/rename_view_popover.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -334,6 +338,7 @@ class _InnerViewItemState extends State<InnerViewItem> {
onMove: widget.isPlaceholder
? (from, to) => _moveViewCrossSection(
context,
null,
widget.view,
widget.parentView,
widget.spaceType,
@@ -690,7 +695,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
spaceType: widget.spaceType,
onEditing: (value) =>
context.read<ViewBloc>().add(ViewEvent.setIsEditing(value)),
onAction: (action, data) {
onAction: (action, data) async {
switch (action) {
case ViewMoreActionType.favorite:
case ViewMoreActionType.unFavorite:
@@ -699,18 +704,38 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
.add(FavoriteEvent.toggle(widget.view));
break;
case ViewMoreActionType.rename:
NavigatorTextFieldDialog(
title: LocaleKeys.disclosureAction_rename.tr(),
autoSelectAllText: true,
value: widget.view.name,
maxLength: 256,
onConfirm: (newValue, _) {
context.read<ViewBloc>().add(ViewEvent.rename(newValue));
},
).show(context);
unawaited(
NavigatorTextFieldDialog(
title: LocaleKeys.disclosureAction_rename.tr(),
autoSelectAllText: true,
value: widget.view.name,
maxLength: 256,
onConfirm: (newValue, _) {
context.read<ViewBloc>().add(ViewEvent.rename(newValue));
},
).show(context),
);
break;
case ViewMoreActionType.delete:
context.read<ViewBloc>().add(const ViewEvent.delete());
// get if current page contains published child views
final childViews =
await ViewBackendService.getAllChildViews(widget.view);
final views = [widget.view, ...childViews];
final containPublishedPage = await Future.wait(
views.map((e) => ViewBackendService.getPublishInfo(e)),
).then((value) => value.where((e) => e.isSuccess));
if (containPublishedPage.isNotEmpty && context.mounted) {
await showConfirmDeletionDialog(
context: context,
name: widget.view.name,
description: LocaleKeys.publish_containsPublishedPage.tr(),
onConfirm: () {
context.read<ViewBloc>().add(const ViewEvent.delete());
},
);
} else if (context.mounted) {
context.read<ViewBloc>().add(const ViewEvent.delete());
}
break;
case ViewMoreActionType.duplicate:
context.read<ViewBloc>().add(const ViewEvent.duplicate());
@@ -726,22 +751,22 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
return;
}
final result = data;
ViewBackendService.updateViewIcon(
await ViewBackendService.updateViewIcon(
viewId: widget.view.id,
viewIcon: result.emoji,
iconType: result.type.toProto(),
);
break;
case ViewMoreActionType.moveTo:
final target = data;
if (target is! ViewPB) {
final value = data;
if (value is! (ViewPB, ViewPB)) {
return;
}
debugPrint(
'Move view ${widget.view.id}, ${widget.view.name} to ${target.id}, ${target.name}',
);
final space = value.$1;
final target = value.$2;
_moveViewCrossSection(
context,
space,
widget.view,
widget.parentView,
widget.spaceType,
@@ -802,6 +827,7 @@ bool isReferencedDatabaseView(ViewPB view, ViewPB? parentView) {
void _moveViewCrossSection(
BuildContext context,
ViewPB? toSpace,
ViewPB view,
ViewPB? parentView,
FolderSpaceType spaceType,
@@ -822,6 +848,17 @@ void _moveViewCrossSection(
final toSection = spaceType == FolderSpaceType.public
? ViewSectionPB.Public
: ViewSectionPB.Private;
final currentSpace = context.read<SpaceBloc>().state.currentSpace;
if (currentSpace != null &&
toSpace != null &&
currentSpace.id != toSpace.id) {
Log.info(
'Move view(${from.name}) to another space(${toSpace.name}), unpublish the view',
);
context.read<ViewBloc>().add(const ViewEvent.unpublish(sync: false));
}
context.read<ViewBloc>().add(
ViewEvent.move(
from,
@@ -192,8 +192,8 @@ class ViewMoreActionTypeWrapper extends CustomActionCell {
sourceView: sourceView,
userProfile: userProfile,
workspaceId: workspaceId,
onSelected: (view) {
onTap(controller, view);
onSelected: (space, view) {
onTap(controller, (space, view));
},
);
},
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/tasks/app_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
@@ -10,6 +9,8 @@ import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart';
import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:toastification/toastification.dart';
export 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart';
@@ -283,3 +284,58 @@ class OkCancelButton extends StatelessWidget {
);
}
}
void showToastNotification(
BuildContext context, {
required String message,
String? description,
}) {
toastification.show(
context: context,
type: ToastificationType.success,
style: ToastificationStyle.flat,
title: FlowyText(message),
description: description != null
? FlowyText.regular(
description,
fontSize: 12,
lineHeight: 1.2,
maxLines: 3,
)
: null,
alignment: Alignment.bottomCenter,
autoCloseDuration: const Duration(milliseconds: 3000),
showProgressBar: false,
backgroundColor: Theme.of(context).colorScheme.surface,
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.4),
),
);
}
Future<void> showConfirmDeletionDialog({
required BuildContext context,
required String name,
required String description,
required VoidCallback onConfirm,
}) {
return showDialog(
context: context,
builder: (_) {
final title = LocaleKeys.space_deleteConfirmation.tr() + name;
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: SizedBox(
width: 440,
child: ConfirmDeletionPopup(
title: title,
description: description,
onConfirm: onConfirm,
),
),
);
},
);
}
@@ -1,10 +1,9 @@
import 'dart:async';
import 'package:flowy_infra/size.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flowy_infra/size.dart';
class FlowyTextField extends StatefulWidget {
final String? hintText;
final String? text;
@@ -37,6 +36,8 @@ class FlowyTextField extends StatefulWidget {
final List<TextInputFormatter>? inputFormatters;
final bool obscureText;
final bool isDense;
final bool readOnly;
final Color? enableBorderColor;
const FlowyTextField({
super.key,
@@ -71,6 +72,8 @@ class FlowyTextField extends StatefulWidget {
this.inputFormatters,
this.obscureText = false,
this.isDense = true,
this.readOnly = false,
this.enableBorderColor,
});
@override
@@ -144,6 +147,7 @@ class FlowyTextFieldState extends State<FlowyTextField> {
@override
Widget build(BuildContext context) {
return TextField(
readOnly: widget.readOnly,
controller: controller,
focusNode: focusNode,
onChanged: (text) {
@@ -178,7 +182,8 @@ class FlowyTextFieldState extends State<FlowyTextField> {
enabledBorder: OutlineInputBorder(
borderRadius: Corners.s8Border,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline,
color: widget.enableBorderColor ??
Theme.of(context).colorScheme.outline,
),
),
isDense: false,
@@ -13,6 +13,7 @@ class RoundedTextButton extends StatelessWidget {
final Color? hoverColor;
final Color? textColor;
final double? fontSize;
final FontWeight? fontWeight;
final EdgeInsets padding;
const RoundedTextButton({
@@ -27,6 +28,7 @@ class RoundedTextButton extends StatelessWidget {
this.hoverColor,
this.textColor,
this.fontSize,
this.fontWeight,
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
});
@@ -42,6 +44,7 @@ class RoundedTextButton extends StatelessWidget {
child: SizedBox.expand(
child: FlowyTextButton(
title ?? '',
fontWeight: fontWeight,
onPressed: onPressed,
fontSize: fontSize,
mainAxisAlignment: MainAxisAlignment.center,
+32
View File
@@ -394,6 +394,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
custom_sliding_segmented_control:
dependency: "direct main"
description:
name: custom_sliding_segmented_control
sha256: "53c3e931c3ae1f696085d1ec70ac8e934da836595a9b7d9b88fdd0fcbf2a5574"
url: "https://pub.dev"
source: hosted
version: "1.8.3"
dart_style:
dependency: transitive
description:
@@ -949,6 +957,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.2"
iconsax_flutter:
dependency: transitive
description:
name: iconsax_flutter
sha256: "95b65699da8ea98f87c5d232f06b0debaaf1ec1332b697e4d90969ec9a93037d"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
image_gallery_saver:
dependency: "direct main"
description:
@@ -1410,6 +1426,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.1"
pausable_timer:
dependency: transitive
description:
name: pausable_timer
sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074"
url: "https://pub.dev"
source: hosted
version: "3.1.0+3"
percent_indicator:
dependency: "direct main"
description:
@@ -2049,6 +2073,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
toastification:
dependency: "direct main"
description:
name: toastification
sha256: "5e751acc2fb5b8d008138dac255d62290fde4e5a24824f29809ac098c3dfe395"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
tuple:
dependency: transitive
description:
+2
View File
@@ -149,6 +149,8 @@ dependencies:
bitsdojo_window: ^0.1.6
flutter_highlight: ^0.7.0
custom_sliding_segmented_control: ^1.8.3
toastification: ^2.0.0
dev_dependencies:
flutter_lints: ^3.0.1
-34
View File
@@ -1,34 +0,0 @@
FROM oven/bun:latest
WORKDIR /app
RUN apt-get update && \
apt-get install -y nginx
RUN bun install cheerio pino axios pino-pretty
COPY . .
RUN addgroup --system nginx && \
adduser --system --no-create-home --disabled-login --ingroup nginx nginx
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
COPY dist/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/nginx.conf
COPY nginx-signed.crt /etc/ssl/certs/nginx-signed.crt
COPY nginx-signed.key /etc/ssl/private/nginx-signed.key
RUN chown -R nginx:nginx /etc/ssl/certs/nginx-signed.crt /etc/ssl/private/nginx-signed.key
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
EXPOSE 80 443
CMD ["/app/start.sh"]
+10 -4
View File
@@ -43,6 +43,16 @@ Before you begin, ensure you have the following installed:
pnpm install
```
- Modify the environment file in `frontend/appflowy_web_app/.env` to add or modify environment variables
```bash
# Example
AF_BASE_URL=https://test.appflowy.cloud
AF_GOTRUE_URL=https://test.appflowy.cloud/gotrue
AF_WS_URL=wss://test.appflowy.cloud/ws/v1
```
#### 🖥️ Desktop Application (Tauri) (Optional)
> **Note**: if you want to run the web app in the browser, skip this step
@@ -148,10 +158,6 @@ Don't modify the theme file in `frontend/appflowy_web_app/src/styles/variables`
pnpm run css:variables
```
#### How to add or modify the environment variables
- Modify the environment file in `frontend/appflowy_web_app/.env` to add or modify environment variables
#### How to create symlink for the @appflowyinc/client-api-wasm in local development
- Run the following command to create a symlink for the @appflowyinc/client-api-wasm
-3
View File
@@ -1,3 +0,0 @@
AF_WS_URL=wss://beta.appflowy.cloud/ws/v1
AF_BASE_URL=https://beta.appflowy.cloud
AF_GOTRUE_URL=https://beta.appflowy.cloud/gotrue
@@ -1,11 +0,0 @@
{
"id": "9eebea03-3ed5-4298-86b2-a7f77856d48b",
"name": "workspace",
"icon": "",
"owner": {
"id": 0,
"name": "system"
},
"type": 0,
"workspaceDatabaseId": "375874be-7a4f-4b7c-8b89-1dc9a39838f4"
}
@@ -1,80 +0,0 @@
[
{
"database_id": "037a985f-f369-4c4a-8011-620012850a68",
"created_at": "1713429700",
"views": [
"48c52cf7-bf98-43fa-96ad-b31aade9b071"
]
},
{
"database_id": "daea6aee-9365-4703-a8e2-a2fa6a07b214",
"created_at": "1714449533",
"views": [
"b6347acb-3174-4f0e-98e9-dcce07e5dbf7"
]
},
{
"database_id": "4c658817-20db-4f56-b7f9-0637a22dfeb6",
"created_at": "0",
"views": [
"7d2148fc-cace-4452-9c5c-96e52e6bf8b5",
"e410747b-5f2f-45a0-b2f7-890ad3001355",
"2143e95d-5dcb-4e0f-bb2c-50944e6e019f",
"a5566e49-f156-4168-9b2d-17926c5da329",
"135615fa-66f7-4451-9b54-d7e99445fca4",
"b4e77203-5c8b-48df-bbc5-2e1143eb0e61",
"a6af311f-cbc8-42c2-b801-7115619c3776"
]
},
{
"database_id": "4c658817-20db-4f56-b7f9-0637a22dfeb6",
"created_at": "0",
"views": [
"7d2148fc-cace-4452-9c5c-96e52e6bf8b5",
"e97877f5-c365-4025-9e6a-e590c4b19dbb",
"f0c59921-04ee-4971-995c-79b7fd8c00e2",
"7eb697cd-6a55-40bb-96ac-0d4a3bc924b2"
]
},
{
"database_id": "ee63da2b-aa2a-4d0b-aab0-59008635363a",
"created_at": "0",
"views": [
"2c1ee95a-1b09-4a1f-8d5e-501bc4861a9d",
"91ea7c08-f6b3-4b81-aa1e-d3664686186f"
]
},
{
"database_id": "e788f014-d0d3-4dfe-81ef-aa1ebb4d6366",
"created_at": "0",
"views": [
"1b0e322d-4909-4c63-914a-d034fc363097",
"350f425b-b671-4e2d-8182-5998a6e62924"
]
},
{
"database_id": "ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d",
"created_at": "0",
"views": [
"0ce13415-6cce-4497-94c6-475ad96c249e",
"e4c89421-12b2-4d02-863d-20949eec9271"
]
},
{
"database_id": "ce267d12-3b61-4ebb-bb03-d65272f5f817",
"created_at": "0",
"views": [
"ee3ae8ce-959a-4df3-8734-40b535ff88e3",
"66a6f3bc-c78f-4f74-a09e-08d4717bf1fd",
"2bf50c03-f41f-4363-b5b1-101216a6c5cc"
]
},
{
"database_id": "87bc006e-c1eb-47fd-9ac6-e39b17956369",
"created_at": "0",
"views": [
"7f233be4-1b4d-46b2-bcfc-f341b8d75267",
"a734a068-e73d-4b4b-853c-4daffea389c0"
]
}
]
@@ -1 +0,0 @@
{"data":{"state_vector":[6,248,208,217,159,7,16,226,250,246,177,3,17,211,142,141,147,5,8,196,148,203,38,26,230,150,209,139,14,193,6,167,238,246,72,86],"doc_state":[6,177,5,230,150,209,139,14,0,0,2,0,1,0,5,39,0,196,148,203,38,4,6,122,45,72,76,78,68,2,4,0,230,150,209,139,14,8,4,49,49,49,49,39,0,196,148,203,38,1,6,54,70,57,105,75,107,1,40,0,230,150,209,139,14,13,2,105,100,1,119,6,54,70,57,105,75,107,40,0,230,150,209,139,14,13,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,230,150,209,139,14,13,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,13,8,99,104,105,108,100,114,101,110,1,119,6,97,118,105,69,115,45,33,0,230,150,209,139,14,13,4,100,97,116,97,1,40,0,230,150,209,139,14,13,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,13,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,97,118,105,69,115,45,0,72,196,148,203,38,24,1,119,6,54,70,57,105,75,107,39,0,196,148,203,38,4,6,69,115,57,50,84,74,2,33,0,196,148,203,38,1,6,98,71,84,69,74,116,1,0,7,33,0,196,148,203,38,3,6,87,56,66,102,66,110,1,129,196,148,203,38,24,1,168,230,150,209,139,14,18,1,119,39,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,49,49,49,49,34,125,93,44,34,108,101,118,101,108,34,58,50,125,4,0,230,150,209,139,14,23,1,35,0,1,132,230,150,209,139,14,35,1,35,0,1,132,230,150,209,139,14,37,1,35,0,1,132,230,150,209,139,14,39,1,35,0,1,39,0,196,148,203,38,4,6,56,76,76,113,48,56,2,39,0,196,148,203,38,1,6,56,70,105,117,112,98,1,40,0,230,150,209,139,14,44,2,105,100,1,119,6,56,70,105,117,112,98,40,0,230,150,209,139,14,44,2,116,121,1,119,7,104,101,97,100,105,110,103,40,0,230,150,209,139,14,44,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,44,8,99,104,105,108,100,114,101,110,1,119,6,49,66,67,68,107,53,33,0,230,150,209,139,14,44,4,100,97,116,97,1,40,0,230,150,209,139,14,44,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,44,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,49,66,67,68,107,53,0,200,196,148,203,38,24,230,150,209,139,14,33,1,119,6,56,70,105,117,112,98,4,0,230,150,209,139,14,43,1,50,161,230,150,209,139,14,49,1,132,230,150,209,139,14,54,1,50,161,230,150,209,139,14,55,1,132,230,150,209,139,14,56,1,50,161,230,150,209,139,14,57,1,132,230,150,209,139,14,58,1,50,161,230,150,209,139,14,59,1,39,0,196,148,203,38,4,6,103,76,57,104,70,45,2,39,0,196,148,203,38,1,6,72,99,71,82,109,56,1,40,0,230,150,209,139,14,63,2,105,100,1,119,6,72,99,71,82,109,56,40,0,230,150,209,139,14,63,2,116,121,1,119,9,112,97,114,97,103,114,97,112,104,40,0,230,150,209,139,14,63,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,63,8,99,104,105,108,100,114,101,110,1,119,6,50,114,117,49,72,109,33,0,230,150,209,139,14,63,4,100,97,116,97,1,40,0,230,150,209,139,14,63,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,63,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,50,114,117,49,72,109,0,136,230,150,209,139,14,33,1,119,6,72,99,71,82,109,56,168,230,150,209,139,14,61,1,119,39,123,34,108,101,118,101,108,34,58,52,44,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,50,50,50,50,34,125,93,125,1,0,230,150,209,139,14,62,1,161,230,150,209,139,14,68,1,129,230,150,209,139,14,74,1,161,230,150,209,139,14,75,1,129,230,150,209,139,14,76,2,161,230,150,209,139,14,77,1,129,230,150,209,139,14,79,1,161,230,150,209,139,14,80,1,129,230,150,209,139,14,81,1,161,230,150,209,139,14,82,1,68,230,150,209,139,14,74,6,228,189,160,229,165,189,168,230,150,209,139,14,84,1,119,31,123,34,100,101,108,116,97,34,58,91,123,34,105,110,115,101,114,116,34,58,34,228,189,160,229,165,189,34,125,93,125,39,0,196,148,203,38,4,6,113,111,68,56,109,111,2,33,0,196,148,203,38,1,6,113,118,66,98,82,83,1,0,7,33,0,196,148,203,38,3,6,118,76,97,51,57,90,1,129,230,150,209,139,14,72,1,1,0,230,150,209,139,14,88,1,0,1,129,230,150,209,139,14,99,1,0,1,129,230,150,209,139,14,101,1,0,4,132,230,150,209,139,14,103,1,91,0,1,132,230,150,209,139,14,108,1,93,0,1,39,0,196,148,203,38,4,6,69,114,99,104,55,72,2,39,0,196,148,203,38,1,6,51,111,95,70,117,67,1,40,0,230,150,209,139,14,113,2,105,100,1,119,6,51,111,95,70,117,67,40,0,230,150,209,139,14,113,2,116,121,1,119,9,116,111,100,111,95,108,105,115,116,40,0,230,150,209,139,14,113,6,112,97,114,101,110,116,1,119,10,74,118,87,74,108,105,53,79,117,84,40,0,230,150,209,139,14,113,8,99,104,105,108,100,114,101,110,1,119,6,68,56,109,75,57,97,33,0,230,150,209,139,14,113,4,100,97,116,97,1,40,0,230,150,209,139,14,113,11,101,120,116,101,114,110,97,108,95,105,100,1,126,40,0,230,150,209,139,14,113,13,101,120,116,101,114,110,97,108,95,116,121,112,101,1,126,39,0,196,148,203,38,3,6,68,56,109,75,57,97,0,200,230,150,209,139,14,72,230,150,209,139,14,98,1,119,6,51,111,95,70,117,67,4,0,230,150,209,139,14,112,1,49,161,230,150,209,139,14,118,1,132,230,150,209,139,14,123,1,50,161,230,150,209,139,14,Line truncated
@@ -1 +0,0 @@
{"data":{"state_vector":[160,1,128,252,161,128,4,33,130,180,254,251,6,18,135,232,133,203,9,15,135,240,136,178,10,45,137,226,192,199,6,20,138,202,240,189,2,134,1,139,240,196,145,14,138,2,139,152,215,249,10,34,141,216,158,150,1,3,142,130,192,134,11,3,143,184,153,180,6,6,140,228,230,243,1,2,145,190,137,224,10,2,145,144,146,185,5,12,140,152,206,145,6,165,1,135,166,246,235,6,3,147,206,229,235,1,3,137,164,190,210,1,39,152,158,185,230,10,7,153,130,203,161,6,97,152,252,186,192,1,2,154,244,246,165,8,198,1,156,148,170,169,10,16,157,240,144,231,2,3,158,156,181,152,10,49,158,182,250,251,9,15,160,192,253,131,5,3,161,178,132,150,11,101,159,156,204,250,6,27,163,236,177,169,4,12,164,188,201,172,1,3,158,184,218,165,3,38,162,238,198,212,7,3,170,140,240,234,9,31,171,204,155,217,8,3,171,142,166,254,1,6,173,252,148,184,13,64,176,154,159,227,1,20,178,162,190,217,10,3,179,252,154,44,3,180,230,210,212,13,7,182,172,247,194,5,3,183,226,184,158,8,8,187,220,199,239,8,70,195,242,227,194,8,3,196,154,250,183,6,62,197,254,154,201,10,3,200,142,208,241,4,157,1,202,160,246,212,1,6,203,184,221,173,11,37,206,220,129,131,4,21,207,228,238,162,8,50,209,250,203,254,15,3,210,228,153,221,12,3,211,202,217,232,12,7,211,166,203,229,4,195,1,214,168,149,214,3,15,219,220,239,171,8,10,225,248,138,176,2,49,226,212,179,248,2,15,229,154,128,197,12,24,234,182,182,157,9,8,235,178,165,206,5,72,234,156,130,211,12,12,241,130,161,205,7,12,244,226,228,149,2,30,245,220,194,52,39,247,200,243,247,14,100,248,136,168,181,1,3,248,210,237,129,13,2,250,198,166,187,7,2,248,196,187,185,10,31,252,218,241,167,14,3,255,140,248,220,6,3,128,211,179,216,2,10,133,159,138,205,12,131,1,135,167,156,250,14,16,135,193,208,135,7,16,141,245,194,142,11,7,141,205,220,149,4,3,143,131,148,152,6,15,141,171,170,217,4,2,145,159,164,217,14,3,149,129,169,191,12,16,149,249,242,175,4,31,149,189,189,215,8,3,149,161,132,184,14,221,2,154,243,157,196,14,12,154,193,208,134,10,9,155,165,205,152,11,3,154,235,215,240,4,13,155,159,180,195,15,6,157,207,243,216,6,2,161,239,241,154,13,106,162,159,252,196,11,3,164,155,139,169,7,35,165,139,157,171,15,103,164,203,250,235,13,7,167,131,133,162,9,11,166,201,221,141,13,72,169,197,188,221,3,3,170,255,211,105,38,166,203,155,46,6,173,187,245,170,14,46,174,151,139,93,179,2,175,225,172,150,8,11,175,147,217,214,1,33,177,161,136,243,11,10,178,203,205,182,4,1,175,205,156,228,6,10,180,205,189,133,13,20,181,175,219,209,12,10,182,143,233,195,4,111,177,219,160,167,7,4,184,201,188,172,10,3,184,231,170,67,6,186,197,166,179,15,7,187,173,214,176,15,3,188,171,136,250,8,21,188,237,223,145,6,26,190,139,191,155,1,2,191,157,147,233,9,32,193,249,142,142,4,17,198,189,216,175,6,23,200,205,214,172,10,30,201,129,238,197,4,84,201,191,159,147,14,17,200,203,236,184,2,35,200,159,185,206,9,8,205,149,231,236,11,68,213,161,242,209,13,35,214,139,213,136,8,66,213,255,156,145,1,2,219,227,140,137,6,34,221,147,167,147,15,177,2,222,205,223,235,7,31,223,209,193,147,11,81,227,209,197,253,2,14,229,153,197,202,7,241,6,231,139,244,188,8,13,232,207,157,148,2,2,233,165,139,246,14,43,233,247,183,159,1,4,235,225,184,133,10,3,234,153,236,158,4,73,234,187,164,181,1,24,231,189,134,196,8,77,239,199,189,146,3,24,240,149,229,225,6,15,241,155,213,233,1,52,240,253,240,229,1,79,243,239,182,181,13,30,236,229,225,232,8,116,246,185,174,192,6,90,248,153,216,10,3,251,189,220,155,14,3,252,163,130,200,6,30,253,205,145,137,11,10,252,171,209,175,15,4,255,255,147,249,10,3],"doc_state":[160,1,3,209,250,203,254,15,0,161,226,212,179,248,2,9,1,161,226,212,179,248,2,10,1,136,174,151,139,93,249,1,1,118,2,2,105,100,119,36,50,54,100,53,99,56,99,49,45,49,99,54,54,45,52,53,57,99,45,98,99,54,99,45,102,52,100,97,49,97,54,54,51,51,52,56,9,116,105,109,101,115,116,97,109,112,122,0,0,0,0,102,83,246,78,2,155,159,180,195,15,0,161,198,189,216,175,6,22,1,161,253,205,145,137,11,9,5,7,186,197,166,179,15,0,161,161,178,132,150,11,98,1,161,161,178,132,150,11,99,1,129,161,178,132,150,11,100,1,161,161,178,132,150,11,97,1,161,161,178,132,150,11,94,1,161,161,178,132,150,11,95,1,129,186,197,166,179,15,2,1,3,187,173,214,176,15,0,168,244,226,228,149,2,27,1,122,4,56,115,160,190,64,16,0,168,244,226,228,149,2,28,1,122,0,0,0,0,102,32,153,171,136,248,153,216,10,2,1,118,2,2,105,100,119,36,48,53,51,51,50,98,97,52,45,97,54,57,48,45,52,50,57,51,45,57,56,54,54,45,56,52,100,97,99,55,102,101,50,97,101,97,9,116,105,109,101,115,116,97,109,112,122,0,0,0,0,102,32,153,171,1,252,171,209,175,15,0,161,128,211,179,216,2,9,4,103,165,139,157,171,15,0,8,0,201,129,238,197,4,52,1,118,1,2,105,100,119,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,168,201,129,238,197,4,51,1,122,0,0,0,0,102,74,244,14,161,170,140,240,234,9,22,1,39,0,203,184,221,173,11,1,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,1,40,0,165,139,157,171,15,3,2,105,100,1,119,36,97,53,53,54,54,101,52,57,45,102,49,53,54,45,52,49,54,56,45,57,98,50,100,45,49,55,57,50,54,99,53,100,97,51,50,57,40,0,Line truncated
@@ -1,66 +0,0 @@
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTI4Mjk2MjAsImlhdCI6MTcxMjgyNjAyMCwic3ViIjoiY2JmZjA2MGEtMTk2ZC00MTVhLWFhODAtNzU5YzAxODg2NDY2IiwiZW1haWwiOiJsdUBhcHBmbG93eS5pbyIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZ29vZ2xlIiwicHJvdmlkZXJzIjpbImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTEhabVZBczRTb0ZlVFFuWG5CU2JiNTBBVXF0YktHNWx5MGllVHZCSklYZ1o3UmdRPXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImFwcGZsb3d5LmlvIn0sImVtYWlsIjoibHVAYXBwZmxvd3kuaW8iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTHUgSGUiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTHUgSGUiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NMSFptVkFzNFNvRmVUUW5YbkJTYmI1MEFVcXRiS0c1bHkwaWVUdkJKSVhnWjdSZ1E9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSIsInN1YiI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSJ9LCJyb2xlIjoiIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoicGFzc3dvcmQiLCJ0aW1lc3RhbXAiOjE3MTI4MjYwMjB9XSwic2Vzc2lvbl9pZCI6ImJmMzE5OTRlLTQwMTgtNDhjMS05Yzc0LWVmYzkyMGNjOWQ0NSJ9.QeTrRhsnBjBL1GUS3TIWOgU1SPM6RcaWwxZdMVfcFBU",
"token_type": "bearer",
"expires_in": 3600,
"expires_at": 4869016461,
"refresh_token": "71vp1jJnSAVluZKaXkhG1A",
"user": {
"id": "cbff060a-196d-415a-aa80-759c01886466",
"aud": "",
"role": "",
"email": "lu@appflowy.io",
"email_confirmed_at": "2024-03-13T10:49:53.165361Z",
"phone": "",
"confirmed_at": "2024-03-13T10:49:53.165361Z",
"last_sign_in_at": "2024-04-11T09:00:20.547468985Z",
"app_metadata": {
"provider": "google",
"providers": [
"google"
]
},
"user_metadata": {
"avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
"custom_claims": {
"hd": "appflowy.io"
},
"email": "lu@appflowy.io",
"email_verified": true,
"full_name": "Lu He",
"iss": "https://accounts.google.com",
"name": "Lu He",
"phone_verified": false,
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
"provider_id": "101169250829554028381",
"sub": "101169250829554028381"
},
"identities": [
{
"identity_id": "e4cf8b69-7f80-42e9-aed2-e25132ad0178",
"id": "101169250829554028381",
"user_id": "cbff060a-196d-415a-aa80-759c01886466",
"identity_data": {
"avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
"custom_claims": {
"hd": "appflowy.io"
},
"email": "lu@appflowy.io",
"email_verified": true,
"full_name": "Lu He",
"iss": "https://accounts.google.com",
"name": "Lu He",
"phone_verified": false,
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
"provider_id": "101169250829554028381",
"sub": "101169250829554028381"
},
"provider": "google",
"last_sign_in_at": "2024-03-13T07:22:43.110504Z",
"created_at": "2024-03-13T07:22:43.110543Z",
"updated_at": "2024-04-04T06:15:14.03093Z"
}
],
"created_at": "2024-03-13T07:22:43.102586Z",
"updated_at": "2024-04-11T09:00:20.551485Z"
}
}
@@ -1,17 +0,0 @@
{
"data": {
"uid": 304120109071339520,
"uuid": "cbff060a-196d-415a-aa80-759c01886466",
"email": "lu@appflowy.io",
"password": "",
"name": "Kilu",
"metadata": {
"icon_url": "🇽🇰"
},
"encryption_sign": null,
"latest_workspace_id": "fcb503f9-9287-4de4-8de0-ea191e680968",
"updated_at": 1710421586
},
"code": 0,
"message": "Operation completed successfully."
}
@@ -1 +0,0 @@
{"data":{"user_profile":{"uid":304120109071339520,"uuid":"cbff060a-196d-415a-aa80-759c01886466","email":"lu@appflowy.io","password":"","name":"Kilu","metadata":{"icon_url":"🇽🇰"},"encryption_sign":null,"latest_workspace_id":"9eebea03-3ed5-4298-86b2-a7f77856d48b","updated_at":1715847453},"visiting_workspace":{"workspace_id":"9eebea03-3ed5-4298-86b2-a7f77856d48b","database_storage_id":"375874be-7a4f-4b7c-8b89-1dc9a39838f4","owner_uid":304120109071339520,"owner_name":"Kilu","workspace_type":0,"workspace_name":"Kilu Works","created_at":"2024-03-13T07:23:10.275174Z","icon":"😆"},"workspaces":[{"workspace_id":"81570fa8-8be9-4b2d-9f1c-1ef4f34079a8","database_storage_id":"6c1f1a2c-e8d5-4bc2-917f-495bce862abb","owner_uid":311828434584080384,"owner_name":"Zack Zi Xiang Fu","workspace_type":0,"workspace_name":"My Workspace","created_at":"2024-04-03T13:53:18.295918Z","icon":""},{"workspace_id":"fcb503f9-9287-4de4-8de0-ea191e680968","database_storage_id":"ae1b82a5-2b93-45c7-901a-f9357c544534","owner_uid":276169796100296704,"owner_name":"Annie Anqi Wang","workspace_type":0,"workspace_name":"AppFlowy Test","created_at":"2023-12-27T04:18:36.372013Z","icon":""},{"workspace_id":"9eebea03-3ed5-4298-86b2-a7f77856d48b","database_storage_id":"375874be-7a4f-4b7c-8b89-1dc9a39838f4","owner_uid":304120109071339520,"owner_name":"Kilu","workspace_type":0,"workspace_name":"Kilu Works","created_at":"2024-03-13T07:23:10.275174Z","icon":"😆"}]},"code":0,"message":"Operation completed successfully."}
@@ -1,6 +0,0 @@
{
"code": 0,
"data": {
"is_new": false
}
}
@@ -25,96 +25,9 @@
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
import { YDoc } from '@/application/collab.type';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { JSDatabaseService } from '@/application/services/js-services/database.service';
import { JSDocumentService } from '@/application/services/js-services/document.service';
import { applyYDoc } from '@/application/ydoc/apply';
import * as Y from 'yjs';
Cypress.Commands.add('mockAPI', () => {
cy.fixture('sign_in_success').then((json) => {
cy.intercept('GET', `/api/user/verify/${json.access_token}`, {
fixture: 'verify_token',
}).as('verifyToken');
cy.intercept('POST', '/gotrue/token?grant_type=password', json).as('loginSuccess');
cy.intercept('POST', '/gotrue/token?grant_type=refresh_token', json).as('refreshToken');
});
cy.intercept('GET', '/api/user/profile', { fixture: 'user' }).as('getUserProfile');
cy.intercept('GET', '/api/user/workspace', { fixture: 'user_workspace' }).as('getUserWorkspace');
// Mock the API
});
// Example use:
// beforeEach(() => {
// cy.mockAPI();
// });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockCurrentWorkspace', () => {
cy.fixture('current_workspace').then((workspace) => {
cy.stub(JSDatabaseService.prototype, 'currentWorkspace').resolves(workspace);
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockGetWorkspaceDatabases', () => {
cy.fixture('database/databases').then((databases) => {
cy.stub(JSDatabaseService.prototype, 'getWorkspaceDatabases').resolves(databases);
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockDatabase', () => {
cy.mockCurrentWorkspace();
cy.mockGetWorkspaceDatabases();
const ids = [
'4c658817-20db-4f56-b7f9-0637a22dfeb6',
'ce267d12-3b61-4ebb-bb03-d65272f5f817',
'ad7dc45b-44b5-498f-bfa2-0f43bf05cc0d',
];
const mockOpenDatabase = cy.stub(JSDatabaseService.prototype, 'openDatabase');
ids.forEach((id) => {
cy.fixture(`database/${id}`).then((database) => {
cy.fixture(`database/rows/${id}`).then((rows) => {
const doc = new Y.Doc();
const rootRowsDoc = new Y.Doc();
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const databaseState = new Uint8Array(database.data.doc_state);
applyYDoc(doc, databaseState);
Object.keys(rows).forEach((key) => {
const data = rows[key];
const rowDoc = new Y.Doc();
applyYDoc(rowDoc, new Uint8Array(data));
rowsFolder.set(key, rowDoc);
});
mockOpenDatabase.withArgs(id).resolves({
databaseDoc: doc,
rows: rowsFolder,
});
});
});
});
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Cypress.Commands.add('mockDocument', (id: string) => {
cy.fixture(`document/${id}`).then((subDocument) => {
const doc = new Y.Doc();
const state = new Uint8Array(subDocument.data.doc_state);
applyYDoc(doc, state);
cy.stub(JSDocumentService.prototype, 'openDocument').withArgs(id).resolves(doc);
});
});
export {};
@@ -0,0 +1,32 @@
FROM oven/bun:latest
WORKDIR /app
RUN apt-get update && \
apt-get install -y nginx supervisor
RUN bun install cheerio pino pino-pretty
COPY . .
COPY supervisord.conf /app/supervisord.conf
RUN addgroup --system nginx && \
adduser --system --no-create-home --disabled-login --ingroup nginx nginx
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
COPY dist /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/nginx.conf
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
RUN chmod +x /app/supervisord.conf
EXPOSE 80
CMD ["supervisord", "-c", "/app/supervisord.conf"]
@@ -0,0 +1,28 @@
if [ -z "$1" ]; then
echo "No port number provided"
exit 1
fi
PORT=$1
echo "Starting deployment on port $PORT"
rm -rf deploy
tar -xzf build-output.tar.gz
rm -rf build-output.tar.gz
mv dist deploy/dist
mv .env deploy/.env
cd deploy
docker system prune -f
docker build -t appflowy-web-app-"$PORT" .
docker rm -f appflowy-web-app-"$PORT" || true
docker run -d --env-file .env -p "$PORT":80 --restart always --name appflowy-web-app-"$PORT" appflowy-web-app-"$PORT"
@@ -40,29 +40,6 @@ http {
server {
listen 80;
server_name localhost;
#server_name appflowy.com *.appflowy.com;
location / {
return 301 https://$host$request_uri;
}
}
# Additional server block for HTTPS
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name localhost;
#server_name appflowy.com *.appflowy.com;
ssl_certificate /etc/ssl/certs/nginx-signed.crt;
ssl_certificate_key /etc/ssl/private/nginx-signed.key;
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://localhost:3000;
@@ -89,6 +66,18 @@ http {
access_log off;
}
location /og-image.png {
root /usr/share/nginx/html;
expires 30d;
access_log off;
}
location /covers/ {
root /usr/share/nginx/html;
expires 30d;
access_log off;
}
error_page 404 /404.html;
location = /404.html {
root /usr/share/nginx/html;
@@ -98,5 +87,6 @@ http {
location = /50x.html {
root /usr/share/nginx/html;
}
}
}
}
+174
View File
@@ -0,0 +1,174 @@
import path from 'path';
import * as fs from 'fs';
import pino from 'pino';
import { type CheerioAPI, load } from 'cheerio';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
import { fetch } from 'bun';
const distDir = path.join(__dirname, 'dist');
const indexPath = path.join(distDir, 'index.html');
const baseURL = process.env.AF_BASE_URL as string;
const defaultSite = 'https://appflowy.io';
const setOrUpdateMetaTag = ($: CheerioAPI, selector: string, attribute: string, content: string) => {
if ($(selector).length === 0) {
$('head').append(`<meta ${attribute}="${selector.match(/\[(.*?)\]/)?.[1]}" content="${content}">`);
} else {
$(selector).attr('content', content);
}
};
const logger = pino({
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
destination: `${__dirname}/pino-logger.log`,
},
},
level: 'info',
});
const logRequestTimer = (req: Request) => {
const start = Date.now();
const pathname = new URL(req.url).pathname;
logger.info(`Incoming request: ${pathname}`);
return () => {
const duration = Date.now() - start;
logger.info(`Request for ${pathname} took ${duration}ms`);
};
};
const fetchMetaData = async (url: string) => {
logger.info(`Fetching meta data from ${url}`);
try {
const response = await fetch(url, {
verbose: true,
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
} catch (error) {
logger.error(`Error fetching meta data ${error}`);
return null;
}
};
const createServer = async (req: Request) => {
const timer = logRequestTimer(req);
const reqUrl = new URL(req.url);
const hostname = req.headers.get('host');
logger.info(`Request URL: ${hostname}${reqUrl.pathname}`);
const [namespace, publishName] = reqUrl.pathname.slice(1).split('/');
logger.info(`Namespace: ${namespace}, Publish Name: ${publishName}`);
if (req.method === 'GET') {
if (namespace === '' || !publishName) {
timer();
return new Response(null, {
status: 302,
headers: {
Location: defaultSite,
},
});
}
let metaData;
try {
metaData = await fetchMetaData(`${baseURL}/api/workspace/published/${namespace}/${publishName}`);
} catch (error) {
logger.error(`Error fetching meta data: ${error}`);
}
const htmlData = fs.readFileSync(indexPath, 'utf8');
const $ = load(htmlData);
const description = 'Write, share, and publish docs quickly on AppFlowy.\nGet started for free.';
let title = 'AppFlowy';
const url = `https://${hostname}${reqUrl.pathname}`;
let image = '/og-image.png';
try {
if (metaData && metaData.view) {
title = `${metaData.view.name} | AppFlowy`;
try {
const cover = metaData.view.extra ? JSON.parse(metaData.view.extra)?.cover : null;
if (cover) {
if (['unsplash', 'custom'].includes(cover.type)) {
image = cover.value;
} else if (cover.type === 'built_in') {
image = `/covers/m_cover_image_${cover.value}.png`;
}
}
} catch (_) {
// Do nothing
}
}
} catch (error) {
logger.error(`Error injecting meta data: ${error}`);
}
$('title').text(title);
setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description);
setOrUpdateMetaTag($, 'meta[property="og:title"]', 'property', title);
setOrUpdateMetaTag($, 'meta[property="og:description"]', 'property', description);
setOrUpdateMetaTag($, 'meta[property="og:image"]', 'property', image);
setOrUpdateMetaTag($, 'meta[property="og:url"]', 'property', url);
setOrUpdateMetaTag($, 'meta[property="og:site_name"]', 'property', 'AppFlowy');
setOrUpdateMetaTag($, 'meta[property="og:type"]', 'property', 'website');
setOrUpdateMetaTag($, 'meta[name="twitter:card"]', 'name', 'summary_large_image');
setOrUpdateMetaTag($, 'meta[name="twitter:title"]', 'name', title);
setOrUpdateMetaTag($, 'meta[name="twitter:description"]', 'name', description);
setOrUpdateMetaTag($, 'meta[name="twitter:image"]', 'name', image);
setOrUpdateMetaTag($, 'meta[name="twitter:site"]', 'name', '@appflowy');
timer();
return new Response($.html(), {
headers: { 'Content-Type': 'text/html' },
});
} else {
timer();
logger.error({ message: 'Method not allowed', method: req.method });
return new Response('Method not allowed', { status: 405 });
}
};
declare const Bun: {
serve: (options: { port: number; fetch: typeof createServer; error: (err: Error) => Response }) => void;
};
const start = () => {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
Bun.serve({
port: 3000,
fetch: createServer,
error: (err) => {
logger.error(`Internal Server Error: ${err}`);
return new Response('Internal Server Error', { status: 500 });
},
});
logger.info('Server is running on port 3000');
logger.info(`Base URL: ${baseURL}`);
} catch (err) {
logger.error(err);
process.exit(1);
}
};
start();
export {};
@@ -1,10 +1,12 @@
#!/bin/bash
#!/usr/bin/env bash
# Start the frontend server
bun run server.cjs &
# Start the nginx server
service nginx start
# Start the frontend server
bun run server.ts
tail -f /dev/null
@@ -0,0 +1,9 @@
[supervisord]
nodaemon=true
[program:bun]
command=sh /app/start.sh
autostart=true
autorestart=true
stderr_logfile=/var/log/bun.err.log
stdout_logfile=/var/log/bun.out.log
+2
View File
@@ -26,6 +26,8 @@
/>
<meta name="twitter:site" content="@appflowy" />
<meta name="twitter:creator" content="@appflowy" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap" rel="stylesheet">
</head>
<body id="body">
<div id="root"></div>
+7 -1
View File
@@ -24,7 +24,7 @@
"coverage": "pnpm run test:unit && pnpm run test:components"
},
"dependencies": {
"@appflowyinc/client-api-wasm": "0.0.3",
"@appflowyinc/client-api-wasm": "0.1.1",
"@atlaskit/primitives": "^5.5.3",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
@@ -43,6 +43,8 @@
"colorthief": "^2.4.0",
"dayjs": "^1.11.9",
"decimal.js": "^10.4.3",
"dexie": "^4.0.7",
"dexie-react-hooks": "^1.1.7",
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.2.1",
"events": "^3.3.0",
@@ -56,6 +58,7 @@
"katex": "^0.16.7",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.0",
"notistack": "^3.0.1",
"numeral": "^2.0.6",
"prismjs": "^1.29.0",
"protoc-gen-ts": "0.8.7",
@@ -136,6 +139,7 @@
"autoprefixer": "^10.4.13",
"babel-jest": "^29.6.2",
"chalk": "^4.1.2",
"cheerio": "1.0.0-rc.12",
"cross-env": "^7.0.3",
"cypress": "^13.7.2",
"eslint": "^8.57.0",
@@ -145,6 +149,8 @@
"istanbul-lib-coverage": "^3.2.2",
"jest-environment-jsdom": "^29.6.2",
"nyc": "^15.1.0",
"pino": "^9.2.0",
"pino-pretty": "^11.2.1",
"postcss": "^8.4.21",
"prettier": "2.8.4",
"prettier-plugin-tailwindcss": "^0.2.2",
+256 -8
View File
@@ -1,9 +1,13 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@appflowyinc/client-api-wasm':
specifier: 0.0.3
version: 0.0.3
specifier: 0.1.1
version: 0.1.1
'@atlaskit/primitives':
specifier: ^5.5.3
version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
@@ -58,6 +62,12 @@ dependencies:
decimal.js:
specifier: ^10.4.3
version: 10.4.3
dexie:
specifier: ^4.0.7
version: 4.0.7
dexie-react-hooks:
specifier: ^1.1.7
version: 1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0)
emoji-mart:
specifier: ^5.5.2
version: 5.6.0
@@ -97,6 +107,9 @@ dependencies:
nanoid:
specifier: ^4.0.0
version: 4.0.2
notistack:
specifier: ^3.0.1
version: 3.0.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0)
numeral:
specifier: ^2.0.6
version: 2.0.6
@@ -333,6 +346,9 @@ devDependencies:
chalk:
specifier: ^4.1.2
version: 4.1.2
cheerio:
specifier: 1.0.0-rc.12
version: 1.0.0-rc.12
cross-env:
specifier: ^7.0.3
version: 7.0.3
@@ -360,6 +376,12 @@ devDependencies:
nyc:
specifier: ^15.1.0
version: 15.1.0
pino:
specifier: ^9.2.0
version: 9.2.0
pino-pretty:
specifier: ^11.2.1
version: 11.2.1
postcss:
specifier: ^8.4.21
version: 8.4.21
@@ -429,8 +451,8 @@ packages:
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25
/@appflowyinc/client-api-wasm@0.0.3:
resolution: {integrity: sha512-ARjLhiDZ8MiZ9egWDbAX9VAdXXS30av+InCPLrS/iqCMYrhuuU9rxS9jQeNEB7jucFrj158gBRusimFN7P/lyw==}
/@appflowyinc/client-api-wasm@0.1.1:
resolution: {integrity: sha512-7+/TCmzMi9KrxX3HFLJv9R6ON2AO5xQavV547ii7RZM8+5bZJakuf6+pnyCzOquQX07q3ZYwJCa3MIgDvficaA==}
dev: false
/@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0):
@@ -4580,6 +4602,13 @@ packages:
deprecated: Use your platform's native atob() and btoa() methods instead
dev: true
/abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
dependencies:
event-target-shim: 5.0.1
dev: true
/acorn-globals@7.0.1:
resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==}
dependencies:
@@ -4886,6 +4915,11 @@ packages:
engines: {node: '>= 4.0.0'}
dev: true
/atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
dev: true
/autoprefixer@10.4.13(postcss@8.4.21):
resolution: {integrity: sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg==}
engines: {node: ^10 || ^12 || >=14}
@@ -5180,6 +5214,13 @@ packages:
ieee754: 1.2.1
dev: true
/buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
dev: true
/cachedir@2.4.0:
resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==}
engines: {node: '>=6'}
@@ -5289,6 +5330,30 @@ packages:
engines: {node: '>= 0.8.0'}
dev: true
/cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
dependencies:
boolbase: 1.0.0
css-select: 5.1.0
css-what: 6.1.0
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.1.0
dev: true
/cheerio@1.0.0-rc.12:
resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==}
engines: {node: '>= 6'}
dependencies:
cheerio-select: 2.1.0
dom-serializer: 2.0.0
domhandler: 5.0.3
domutils: 3.1.0
htmlparser2: 8.0.2
parse5: 7.1.2
parse5-htmlparser2-tree-adapter: 7.0.0
dev: true
/chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@@ -5739,6 +5804,10 @@ packages:
dependencies:
'@babel/runtime': 7.24.1
/dateformat@4.6.3:
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
dev: true
/dayjs@1.11.10:
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
dev: true
@@ -5860,6 +5929,22 @@ packages:
minimist: 1.2.8
dev: true
/dexie-react-hooks@1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0):
resolution: {integrity: sha512-Lwv5W0Hk+uOW3kGnsU9GZoR1er1B7WQ5DSdonoNG+focTNeJbHW6vi6nBoX534VKI3/uwHebYzSw1fwY6a7mTw==}
peerDependencies:
'@types/react': '>=16'
dexie: ^3.2 || ^4.0.1-alpha
react: '>=16'
dependencies:
'@types/react': 18.2.66
dexie: 4.0.7
react: 18.2.0
dev: false
/dexie@4.0.7:
resolution: {integrity: sha512-M+Lo6rk4pekIfrc2T0o2tvVJwL6EAAM/B78DNfb8aaxFVoI1f8/rz5KTxuAnApkwqTSuxx7T5t0RKH7qprapGg==}
dev: false
/didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dev: true
@@ -6372,6 +6457,11 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
dev: true
/eventemitter2@6.4.7:
resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==}
dev: true
@@ -6455,6 +6545,10 @@ packages:
resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==}
engines: {'0': node >=0.6.0}
/fast-copy@3.0.2:
resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==}
dev: true
/fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -6492,6 +6586,15 @@ packages:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
dev: true
/fast-redact@3.5.0:
resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==}
engines: {node: '>=6'}
dev: true
/fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
dev: true
/fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
dependencies:
@@ -6961,6 +7064,10 @@ packages:
tslib: 2.6.2
dev: true
/help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
dev: true
/hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
dependencies:
@@ -6983,6 +7090,15 @@ packages:
void-elements: 3.1.0
dev: false
/htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.1.0
entities: 4.5.0
dev: true
/http-proxy-agent@5.0.0:
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
engines: {node: '>= 6'}
@@ -7881,6 +7997,11 @@ packages:
- supports-color
- ts-node
/joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
dev: true
/jpeg-js@0.4.4:
resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
dev: false
@@ -8440,6 +8561,21 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/notistack@3.0.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==}
engines: {node: '>=12.0.0', npm: '>=6.0.0'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
clsx: 1.2.1
goober: 2.1.14(csstype@3.1.3)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
- csstype
dev: false
/npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -8576,6 +8712,11 @@ packages:
resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==}
dev: false
/on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
dev: true
/once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
dependencies:
@@ -8721,6 +8862,13 @@ packages:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
/parse5-htmlparser2-tree-adapter@7.0.0:
resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==}
dependencies:
domhandler: 5.0.3
parse5: 7.1.2
dev: true
/parse5@7.1.2:
resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
dependencies:
@@ -8810,6 +8958,54 @@ packages:
engines: {node: '>=6'}
dev: true
/pino-abstract-transport@1.2.0:
resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==}
dependencies:
readable-stream: 4.5.2
split2: 4.2.0
dev: true
/pino-pretty@11.2.1:
resolution: {integrity: sha512-O05NuD9tkRasFRWVaF/uHLOvoRDFD7tb5VMertr78rbsYFjYp48Vg3477EshVAF5eZaEw+OpDl/tu+B0R5o+7g==}
hasBin: true
dependencies:
colorette: 2.0.20
dateformat: 4.6.3
fast-copy: 3.0.2
fast-safe-stringify: 2.1.1
help-me: 5.0.0
joycon: 3.1.1
minimist: 1.2.8
on-exit-leak-free: 2.1.2
pino-abstract-transport: 1.2.0
pump: 3.0.0
readable-stream: 4.5.2
secure-json-parse: 2.7.0
sonic-boom: 4.0.1
strip-json-comments: 3.1.1
dev: true
/pino-std-serializers@7.0.0:
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
dev: true
/pino@9.2.0:
resolution: {integrity: sha512-g3/hpwfujK5a4oVbaefoJxezLzsDgLcNJeITvC6yrfwYeT9la+edCK42j5QpEQSQCZgTKapXvnQIdgZwvRaZug==}
hasBin: true
dependencies:
atomic-sleep: 1.0.0
fast-redact: 3.5.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 1.2.0
pino-std-serializers: 7.0.0
process-warning: 3.0.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.4.3
sonic-boom: 4.0.1
thread-stream: 3.1.0
dev: true
/pirates@4.0.6:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
@@ -9027,6 +9223,10 @@ packages:
fromentries: 1.3.2
dev: true
/process-warning@3.0.0:
resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==}
dev: true
/process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
@@ -9115,6 +9315,10 @@ packages:
resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==}
dev: true
/quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
dev: true
/quick-lru@5.1.1:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
@@ -9635,12 +9839,28 @@ packages:
util-deprecate: 1.0.2
dev: true
/readable-stream@4.5.2:
resolution: {integrity: sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
dev: true
/readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
dependencies:
picomatch: 2.3.1
/real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
dev: true
/redux-thunk@3.1.0(redux@5.0.1):
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
@@ -9924,6 +10144,11 @@ packages:
is-regex: 1.1.4
dev: true
/safe-stable-stringify@2.4.3:
resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==}
engines: {node: '>=10'}
dev: true
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -9973,6 +10198,10 @@ packages:
compute-scroll-into-view: 3.1.0
dev: false
/secure-json-parse@2.7.0:
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
dev: true
/semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -10151,6 +10380,12 @@ packages:
tslib: 2.6.2
dev: true
/sonic-boom@4.0.1:
resolution: {integrity: sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==}
dependencies:
atomic-sleep: 1.0.0
dev: true
/source-map-js@1.2.0:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'}
@@ -10194,6 +10429,11 @@ packages:
which: 2.0.2
dev: true
/split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
dev: true
/sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@@ -10306,6 +10546,12 @@ packages:
safe-buffer: 5.1.2
dev: true
/string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
dependencies:
safe-buffer: 5.2.1
dev: true
/strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -10499,6 +10745,12 @@ packages:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
/thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
dependencies:
real-require: 0.2.0
dev: true
/throttleit@1.0.1:
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
dev: true
@@ -11414,7 +11666,3 @@ packages:
resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==}
engines: {node: '>=12.20'}
dev: true
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

@@ -0,0 +1,35 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const jestCoverageFile = path.join(__dirname, '../coverage/jest/coverage-final.json');
const cypressCoverageFile = path.join(__dirname, '../coverage/cypress/coverage-final.json');
const nycOutputDir = path.join(__dirname, '../coverage/.nyc_output');
// Ensure .nyc_output directory exists
if (fs.existsSync(nycOutputDir)) {
fs.rmSync(nycOutputDir, { recursive: true });
}
fs.mkdirSync(nycOutputDir, { recursive: true });
if (fs.existsSync(path.join(__dirname, '../coverage/merged'))) {
fs.rmSync(path.join(__dirname, '../coverage/merged'), { recursive: true });
}
// Copy Jest coverage file
fs.copyFileSync(jestCoverageFile, path.join(nycOutputDir, 'jest-coverage.json'));
// Copy Cypress E2E coverage file
fs.copyFileSync(cypressCoverageFile, path.join(nycOutputDir, 'cypress-coverage.json'));
// Merge coverage files
execSync('nyc merge ./coverage/.nyc_output ./coverage/merged/coverage-final.json', { stdio: 'inherit' });
// Move the merged result to the .nyc_output directory
fs.rmSync(nycOutputDir, { recursive: true });
fs.mkdirSync(nycOutputDir, { recursive: true });
fs.copyFileSync(path.join(__dirname, '../coverage/merged/coverage-final.json'), path.join(nycOutputDir, 'out.json'));
// Generate final merged report
execSync('nyc report --reporter=html --reporter=text-summary --report-dir=coverage/merged --temp-dir=coverage/.nyc_output', { stdio: 'inherit' });
console.log(`Merged coverage report written to coverage/merged`);
-114
View File
@@ -1,114 +0,0 @@
const path = require('path');
const fs = require('fs');
const pino = require('pino');
const cheerio = require('cheerio');
const axios = require('axios');
const distDir = path.join(__dirname, 'dist');
const indexPath = path.join(distDir, 'index.html');
const setOrUpdateMetaTag = ($, selector, attribute, content) => {
if ($(selector).length === 0) {
$('head').append(`<meta ${attribute}="${selector.match(/\[(.*?)\]/)[1]}" content="${content}">`);
} else {
$(selector).attr('content', content);
}
};
// Create a new logger instance
const logger = pino({
transport: {
target: 'pino-pretty',
level: 'info',
options: {
colorize: true,
translateTime: 'SYS:standard',
destination: `${__dirname}/pino-logger.log`,
},
},
});
const logRequestTimer = (req) => {
const start = Date.now();
const pathname = new URL(req.url).pathname;
logger.info(`Incoming request: ${pathname}`);
return () => {
const duration = Date.now() - start;
logger.info(`Request for ${pathname} took ${duration}ms`);
};
};
const fetchMetaData = async (url) => {
try {
const response = await axios.get(url);
return response.data;
} catch (error) {
logger.error('Error fetching meta data', error);
return null;
}
};
const createServer = async (req) => {
const timer = logRequestTimer(req);
if (req.method === 'GET') {
const pageId = req.url.split('/').pop();
let htmlData = fs.readFileSync(indexPath, 'utf8');
const $ = cheerio.load(htmlData);
if (!pageId) {
timer();
return new Response($.html(), {
headers: { 'Content-Type': 'text/html' },
});
}
const description = 'Write, share, comment, react, and publish docs quickly and securely on AppFlowy.';
let title = 'AppFlowy';
const url = 'https://appflowy.com';
let image = 'https://d3uafhn8yrvdfn.cloudfront.net/website/production/_next/static/media/og-image.e347bfb5.png';
// Inject meta data into the HTML to support SEO and social sharing
// if (metaData) {
// title = metaData.title;
// image = metaData.image;
// }
$('title').text(title);
setOrUpdateMetaTag($, 'meta[name="description"]', 'name', description);
setOrUpdateMetaTag($, 'meta[property="og:title"]', 'property', title);
setOrUpdateMetaTag($, 'meta[property="og:description"]', 'property', description);
setOrUpdateMetaTag($, 'meta[property="og:image"]', 'property', image);
setOrUpdateMetaTag($, 'meta[property="og:url"]', 'property', url);
setOrUpdateMetaTag($, 'meta[property="og:type"]', 'property', 'article');
setOrUpdateMetaTag($, 'meta[name="twitter:card"]', 'name', 'summary_large_image');
setOrUpdateMetaTag($, 'meta[name="twitter:title"]', 'name', title);
setOrUpdateMetaTag($, 'meta[name="twitter:description"]', 'name', description);
setOrUpdateMetaTag($, 'meta[name="twitter:image"]', 'name', image);
timer();
return new Response($.html(), {
headers: { 'Content-Type': 'text/html' },
});
} else {
timer();
logger.error({ message: 'Method not allowed', method: req.method });
return new Response('Method not allowed', { status: 405 });
}
};
const start = () => {
try {
Bun.serve({
port: 3000,
fetch: createServer,
error: (err) => {
logger.error(`Internal Server Error: ${err}`);
return new Response('Internal Server Error', { status: 500 });
},
});
logger.info(`Server is running on port 3000`);
} catch (err) {
logger.error(err);
process.exit(1);
}
};
start();
@@ -32,6 +32,7 @@ export enum BlockType {
OutlineBlock = 'outline',
TableBlock = 'table',
TableCell = 'table/cell',
LinkPreview = 'link_preview',
}
export enum InlineBlockType {
@@ -46,7 +47,7 @@ export enum AlignType {
}
export interface BlockData {
bg_color?: string;
bgColor?: string;
font_color?: string;
align?: AlignType;
}
@@ -79,6 +80,10 @@ export interface MathEquationBlockData extends BlockData {
formula?: string;
}
export interface LinkPreviewBlockData extends BlockData {
url?: string;
}
export enum ImageType {
Local = 0,
Internal = 1,
@@ -636,3 +641,27 @@ export enum LineHeightLayout {
normal = 'normal',
large = 'large',
}
export interface ViewMetaIcon {
ty: number;
value: string;
}
export interface PublishViewInfo {
view_id: string;
name: string;
icon: ViewMetaIcon | null;
extra: string | null;
layout: number;
created_at: string;
created_by: string;
last_edited_time: string;
last_edited_by: string;
child_views: PublishViewInfo[] | null;
}
export interface PublishViewMetaData {
view: PublishViewInfo;
child_views: PublishViewInfo[];
ancestor_views: PublishViewInfo[];
}
@@ -18,7 +18,6 @@ import {
useSortsSelector,
} from '../selector';
import { useDatabaseViewId } from '../context';
import { IdProvider } from '@/components/_shared/context-provider/IdProvider';
import { DatabaseContextProvider } from '@/components/database/DatabaseContext';
import { withTestingDatabase } from '@/application/database-yjs/__tests__/withTestingData';
import { expect } from '@jest/globals';
@@ -31,11 +30,9 @@ const wrapperCreator =
(viewId: string, doc: YDoc, rowDocMap: Y.Map<YDoc>) =>
({ children }: { children: React.ReactNode }) => {
return (
<IdProvider objectId={viewId}>
<DatabaseContextProvider viewId={viewId} databaseDoc={doc} rowDocMap={rowDocMap} readOnly={true}>
{children}
</DatabaseContextProvider>
</IdProvider>
<DatabaseContextProvider viewId={viewId} databaseDoc={doc} rowDocMap={rowDocMap} readOnly={true}>
{children}
</DatabaseContextProvider>
);
};
@@ -1,4 +1,5 @@
import { YDatabase, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { ViewMeta } from '@/application/db/tables/view_metas';
import { createContext, useContext } from 'react';
import * as Y from 'yjs';
@@ -9,6 +10,10 @@ export interface DatabaseContextState {
rowDocMap: Y.Map<YDoc>;
isDatabaseRowPage?: boolean;
navigateToRow?: (rowId: string) => void;
loadView?: (viewId: string) => Promise<YDoc>;
getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
loadViewMeta?: (viewId: string) => Promise<ViewMeta>;
navigateToView?: (viewId: string) => Promise<void>;
}
export const DatabaseContext = createContext<DatabaseContextState | null>(null);
@@ -1,12 +1,4 @@
import {
FieldId,
SortId,
YDatabaseField,
YDoc,
YjsDatabaseKey,
YjsEditorKey,
YjsFolderKey,
} from '@/application/collab.type';
import { FieldId, SortId, YDatabaseField, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { getCell, metaIdFromRowId, MIN_COLUMN_WIDTH } from '@/application/database-yjs/const';
import {
useDatabase,
@@ -19,7 +11,6 @@ import {
import { filterBy, parseFilter } from '@/application/database-yjs/filter';
import { groupByField } from '@/application/database-yjs/group';
import { sortBy } from '@/application/database-yjs/sort';
import { useViewsIdSelector } from '@/application/folder-yjs';
import { parseYDatabaseCellToCell } from '@/application/database-yjs/cell.parse';
import { DateTimeCell } from '@/application/database-yjs/cell.type';
import * as dayjs from 'dayjs';
@@ -42,9 +33,8 @@ export interface Row {
const defaultVisible = [FieldVisibility.AlwaysShown, FieldVisibility.HideWhenEmpty];
export function useDatabaseViewsSelector(iidIndex: string) {
export function useDatabaseViewsSelector(_iidIndex: string) {
const database = useDatabase();
const { viewsId: visibleViewsId, views: folderViews } = useViewsIdSelector();
const views = database?.get(YjsDatabaseKey.views);
const [viewIds, setViewIds] = useState<string[]>([]);
@@ -65,22 +55,7 @@ export function useDatabaseViewsSelector(iidIndex: string) {
return Number(viewB.created_at) - Number(viewA.created_at);
});
const viewsId = [];
for (const viewItem of viewsSorted) {
const [key] = viewItem;
const view = folderViews?.get(key);
if (
visibleViewsId.includes(key) &&
view &&
(view.get(YjsFolderKey.bid) === iidIndex || view.get(YjsFolderKey.id) === iidIndex)
) {
viewsId.push(key);
}
}
setViewIds(viewsId);
setViewIds(viewsSorted.map(([key]) => key));
};
observerEvent();
@@ -89,7 +64,7 @@ export function useDatabaseViewsSelector(iidIndex: string) {
return () => {
views.unobserve(observerEvent);
};
}, [visibleViewsId, views, folderViews, iidIndex]);
}, [views]);
return {
childViews,
@@ -2,6 +2,17 @@ import { YDoc } from '@/application/collab.type';
import { databasePrefix } from '@/application/constants';
import { IndexeddbPersistence } from 'y-indexeddb';
import * as Y from 'yjs';
import BaseDexie from 'dexie';
import { viewMetasSchema, ViewMetasTable } from '@/application/db/tables/view_metas';
type DexieTables = ViewMetasTable;
export type Dexie<T = DexieTables> = BaseDexie & T;
export const db = new BaseDexie(`${databasePrefix}_cache`) as Dexie;
const schema = Object.assign({}, viewMetasSchema);
db.version(1).stores(schema);
const openedSet = new Set<string>();
@@ -32,10 +43,16 @@ export async function openCollabDB(docName: string): Promise<YDoc> {
return doc as YDoc;
}
export function getCollabDBName(id: string, type: string, uuid?: string) {
if (!uuid) {
return `${type}_${id}`;
export async function closeCollabDB(docName: string) {
const name = `${databasePrefix}_${docName}`;
if (openedSet.has(name)) {
openedSet.delete(name);
}
return `${uuid}_${type}_${id}`;
const doc = new Y.Doc();
const provider = new IndexeddbPersistence(name, doc);
await provider.destroy();
}
@@ -0,0 +1,17 @@
import { Table } from 'dexie';
import { PublishViewInfo } from '@/application/collab.type';
export type ViewMeta = {
publish_name: string;
child_views: PublishViewInfo[];
ancestor_views: PublishViewInfo[];
} & PublishViewInfo;
export type ViewMetasTable = {
view_metas: Table<ViewMeta>;
};
export const viewMetasSchema = {
view_metas: 'publish_name',
};
@@ -1,38 +0,0 @@
import { ViewLayout, YFolder, YjsFolderKey } from '@/application/collab.type';
import { createContext, useContext } from 'react';
import { useParams } from 'react-router-dom';
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)?.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;
};
@@ -1,9 +0,0 @@
export enum CoverType {
NormalColor = 'color',
GradientColor = 'gradient',
BuildInImage = 'built_in',
CustomImage = 'custom',
LocalImage = 'local',
UpsplashImage = 'unsplash',
None = 'none',
}
@@ -1,2 +0,0 @@
export * from './selector';
export * from './context';
@@ -1,70 +0,0 @@
import { YjsFolderKey, YView } from '@/application/collab.type';
import { useFolderContext } from '@/application/folder-yjs/context';
import { useEffect, useState } from 'react';
export function useViewsIdSelector() {
const folder = useFolderContext();
const [viewsId, setViewsId] = useState<string[]>([]);
const views = folder?.get(YjsFolderKey.views);
const trash = folder?.get(YjsFolderKey.section)?.get(YjsFolderKey.trash);
const meta = folder?.get(YjsFolderKey.meta);
useEffect(() => {
if (!views) {
return;
}
const trashUid = trash ? Array.from(trash.keys())[0] : null;
const userTrash = trashUid ? trash?.get(trashUid) : null;
const collectIds = () => {
const trashIds = userTrash?.toJSON()?.map((item) => item.id) || [];
return Array.from(views.keys()).filter((id) => {
return !trashIds.includes(id) && id !== meta?.get(YjsFolderKey.current_workspace);
});
};
setViewsId(collectIds());
const observerEvent = () => setViewsId(collectIds());
views.observe(observerEvent);
userTrash?.observe(observerEvent);
return () => {
views.unobserve(observerEvent);
userTrash?.unobserve(observerEvent);
};
}, [views, trash, meta]);
return {
viewsId,
views,
};
}
export function useViewSelector(viewId: string) {
const folder = useFolderContext();
const [clock, setClock] = useState<number>(0);
const [view, setView] = useState<YView | null>(null);
useEffect(() => {
if (!folder) return;
const view = folder.get(YjsFolderKey.views)?.get(viewId);
setView(view || null);
const observerEvent = () => setClock((prev) => prev + 1);
view?.observe(observerEvent);
return () => {
view?.unobserve(observerEvent);
};
}, [folder, viewId]);
return {
clock,
view,
};
}
@@ -0,0 +1,151 @@
import { YDoc } from '@/application/collab.type';
import { db } from '@/application/db';
import { ViewMeta } from '@/application/db/tables/view_metas';
import { AFConfigContext } from '@/components/app/AppConfig';
import { useLiveQuery } from 'dexie-react-hooks';
import { createContext, useCallback, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import * as Y from 'yjs';
export interface PublishContextType {
namespace: string;
publishName: string;
viewMeta?: ViewMeta;
toView: (viewId: string) => Promise<void>;
loadViewMeta: (viewId: string) => Promise<ViewMeta>;
getViewRowsMap?: (viewId: string, rowIds: string[]) => Promise<{ rows: Y.Map<YDoc>; destroy: () => void }>;
loadView: (viewId: string) => Promise<YDoc>;
}
export const PublishContext = createContext<PublishContextType | null>(null);
export const PublishProvider = ({
children,
namespace,
publishName,
}: {
children: React.ReactNode;
namespace: string;
publishName: string;
}) => {
const viewMeta = useLiveQuery(async () => {
const name = `${namespace}_${publishName}`;
return db.view_metas.get(name);
}, [namespace, publishName]);
const service = useContext(AFConfigContext)?.service;
const navigate = useNavigate();
const toView = useCallback(
async (viewId: string) => {
try {
const res = await service?.getPublishInfo(viewId);
if (!res) {
throw new Error('Not found');
}
const { namespace, publishName } = res;
navigate(`/${namespace}/${publishName}`);
} catch (e) {
return Promise.reject(e);
}
},
[navigate, service]
);
const loadViewMeta = useCallback(
async (viewId: string) => {
try {
const info = await service?.getPublishInfo(viewId);
if (!info) {
throw new Error('View has not been published yet');
}
const { namespace, publishName } = info;
const res = await service?.getPublishViewMeta(namespace, publishName);
if (!res) {
throw new Error('View meta has not been published yet');
}
return res;
} catch (e) {
return Promise.reject(e);
}
},
[service]
);
const getViewRowsMap = useCallback(
async (viewId: string, rowIds: string[]) => {
try {
const info = await service?.getPublishInfo(viewId);
if (!info) {
throw new Error('View has not been published yet');
}
const { namespace, publishName } = info;
const res = await service?.getPublishDatabaseViewRows(namespace, publishName, rowIds);
if (!res) {
throw new Error('View has not been published yet');
}
return res;
} catch (e) {
return Promise.reject(e);
}
},
[service]
);
const loadView = useCallback(
async (viewId: string) => {
try {
const res = await service?.getPublishInfo(viewId);
if (!res) {
throw new Error('View has not been published yet');
}
const { namespace, publishName } = res;
const data = service?.getPublishView(namespace, publishName);
if (!data) {
throw new Error('View has not been published yet');
}
return data;
} catch (e) {
return Promise.reject(e);
}
},
[service]
);
return (
<PublishContext.Provider
value={{
loadView,
viewMeta,
getViewRowsMap,
loadViewMeta,
toView,
namespace,
publishName,
}}
>
{children}
</PublishContext.Provider>
);
};
export function usePublishContext() {
return useContext(PublishContext);
}
@@ -0,0 +1 @@
export * from './context';
@@ -1,310 +0,0 @@
import { CollabType } from '@/application/collab.type';
import * as Y from 'yjs';
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
import { expect } from '@jest/globals';
import { getCollab, batchCollab, collabTypeToDBType } from '../cache';
import { applyYDoc } from '@/application/ydoc/apply';
import { getCollabDBName, openCollabDB } from '../cache/db';
import { StrategyType } from '../cache/types';
jest.mock('@/application/ydoc/apply', () => ({
applyYDoc: jest.fn(),
}));
jest.mock('../cache/db', () => ({
openCollabDB: jest.fn(),
getCollabDBName: jest.fn(),
}));
const emptyDoc = new Y.Doc();
const normalDoc = withTestingYDoc('1');
const mockFetcher = jest.fn();
const mockBatchFetcher = jest.fn();
describe('Cache functions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('getCollab', () => {
describe('with CACHE_ONLY strategy', () => {
it('should throw error when no cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
await expect(
getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.CACHE_ONLY
)
).rejects.toThrow('No cache found');
});
it('should fetch collab with CACHE_ONLY strategy and existing cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
const result = await getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.CACHE_ONLY
);
expect(result).toBe(normalDoc);
expect(mockFetcher).not.toHaveBeenCalled();
expect(applyYDoc).not.toHaveBeenCalled();
});
});
describe('with CACHE_FIRST strategy', () => {
it('should fetch collab with CACHE_FIRST strategy and existing cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
const result = await getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.CACHE_FIRST
);
expect(result).toBe(normalDoc);
expect(mockFetcher).not.toHaveBeenCalled();
expect(applyYDoc).not.toHaveBeenCalled();
});
it('should fetch collab with CACHE_FIRST strategy and no cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
const result = await getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.CACHE_FIRST
);
expect(result).toBe(emptyDoc);
expect(mockFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
});
describe('with CACHE_AND_NETWORK strategy', () => {
it('should fetch collab with CACHE_AND_NETWORK strategy and existing cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
const result = await getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.CACHE_AND_NETWORK
);
expect(result).toBe(normalDoc);
expect(mockFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
it('should fetch collab with CACHE_AND_NETWORK strategy and no cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
const result = await getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.CACHE_AND_NETWORK
);
expect(result).toBe(emptyDoc);
expect(mockFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
});
describe('with default strategy', () => {
it('should fetch collab with default strategy', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockFetcher.mockResolvedValue({ state: new Uint8Array() });
const result = await getCollab(
mockFetcher,
{
collabId: 'id1',
collabType: CollabType.Document,
},
StrategyType.NETWORK_ONLY
);
expect(result).toBe(normalDoc);
expect(mockFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
});
});
describe('batchCollab', () => {
describe('with CACHE_ONLY strategy', () => {
it('should batch fetch collabs with CACHE_ONLY strategy and no cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
await expect(
batchCollab(
mockBatchFetcher,
[
{
collabId: 'id1',
collabType: CollabType.Document,
},
],
StrategyType.CACHE_ONLY
)
).rejects.toThrow('No cache found');
});
it('should batch fetch collabs with CACHE_ONLY strategy and existing cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
await batchCollab(
mockBatchFetcher,
[
{
collabId: 'id1',
collabType: CollabType.Document,
},
],
StrategyType.CACHE_ONLY
);
expect(mockBatchFetcher).not.toHaveBeenCalled();
});
});
describe('with CACHE_FIRST strategy', () => {
it('should batch fetch collabs with CACHE_FIRST strategy and existing cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
await batchCollab(
mockBatchFetcher,
[
{
collabId: 'id1',
collabType: CollabType.Document,
},
],
StrategyType.CACHE_FIRST
);
expect(mockBatchFetcher).not.toHaveBeenCalled();
});
it('should batch fetch collabs with CACHE_FIRST strategy and no cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockBatchFetcher.mockResolvedValue({ id1: [1, 2, 3] });
await batchCollab(
mockBatchFetcher,
[
{
collabId: 'id1',
collabType: CollabType.Document,
},
],
StrategyType.CACHE_FIRST
);
expect(mockBatchFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
});
describe('with CACHE_AND_NETWORK strategy', () => {
it('should batch fetch collabs with CACHE_AND_NETWORK strategy', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockBatchFetcher.mockResolvedValue({ id1: [1, 2, 3] });
await batchCollab(
mockBatchFetcher,
[
{
collabId: 'id1',
collabType: CollabType.Document,
},
],
StrategyType.CACHE_AND_NETWORK
);
expect(mockBatchFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
it('should batch fetch collabs with CACHE_AND_NETWORK strategy and no cache', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(emptyDoc);
(getCollabDBName as jest.Mock).mockReturnValue('testDB');
mockBatchFetcher.mockResolvedValue({ id1: [1, 2, 3] });
await batchCollab(
mockBatchFetcher,
[
{
collabId: 'id1',
collabType: CollabType.Document,
},
],
StrategyType.CACHE_AND_NETWORK
);
expect(mockBatchFetcher).toHaveBeenCalled();
expect(applyYDoc).toHaveBeenCalled();
});
});
});
});
describe('collabTypeToDBType', () => {
it('should return correct DB type', () => {
expect(collabTypeToDBType(CollabType.Document)).toBe('document');
expect(collabTypeToDBType(CollabType.Folder)).toBe('folder');
expect(collabTypeToDBType(CollabType.Database)).toBe('database');
expect(collabTypeToDBType(CollabType.WorkspaceDatabase)).toBe('databases');
expect(collabTypeToDBType(CollabType.DatabaseRow)).toBe('database_row');
expect(collabTypeToDBType(CollabType.UserAwareness)).toBe('user_awareness');
expect(collabTypeToDBType(CollabType.Empty)).toBe('');
});
});
@@ -1,13 +1,13 @@
import { expect } from '@jest/globals';
import { fetchCollab, batchFetchCollab } from '../fetch';
import { CollabType } from '@/application/collab.type';
import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '../fetch';
import { APIService } from '@/application/services/js-services/wasm';
jest.mock('@/application/services/js-services/wasm', () => {
return {
APIService: {
getCollab: jest.fn(),
batchGetCollab: jest.fn(),
getPublishView: jest.fn(),
getPublishViewMeta: jest.fn(),
getPublishInfoWithViewId: jest.fn(),
},
};
});
@@ -17,41 +17,100 @@ describe('Collab fetch functions with deduplication', () => {
jest.clearAllMocks();
});
describe('fetchCollab', () => {
it('should fetch collab without duplicating requests', async () => {
const workspaceId = 'workspace1';
const id = 'id1';
const type = CollabType.Document;
describe('fetchPublishView', () => {
it('should fetch publish view without duplicating requests', async () => {
const namespace = 'namespace1';
const publishName = 'publish1';
const mockResponse = { data: 'mockData' };
(APIService.getCollab as jest.Mock).mockResolvedValue(mockResponse);
(APIService.getPublishView as jest.Mock).mockResolvedValue(mockResponse);
const result1 = fetchCollab(workspaceId, id, type);
const result2 = fetchCollab(workspaceId, id, type);
const result1 = fetchPublishView(namespace, publishName);
const result2 = fetchPublishView(namespace, publishName);
expect(result1).toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
expect(APIService.getCollab).toHaveBeenCalledTimes(1);
expect(APIService.getPublishView).toHaveBeenCalledTimes(1);
});
it('should fetch publish view with different params', async () => {
const namespace = 'namespace1';
const publishName = 'publish1';
const mockResponse = { data: 'mockData' };
(APIService.getPublishView as jest.Mock).mockResolvedValue(mockResponse);
const result1 = fetchPublishView(namespace, publishName);
const result2 = fetchPublishView(namespace, 'publish2');
expect(result1).not.toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
await expect(result2).resolves.toEqual(mockResponse);
expect(APIService.getPublishView).toHaveBeenCalledTimes(2);
});
});
describe('batchFetchCollab', () => {
it('should batch fetch collabs without duplicating requests', async () => {
const workspaceId = 'workspace1';
const params = [
{ collabId: 'id1', collabType: CollabType.Document },
{ collabId: 'id2', collabType: CollabType.Folder },
];
describe('fetchViewInfo', () => {
it('should fetch view info without duplicating requests', async () => {
const viewId = 'view1';
const mockResponse = { data: 'mockData' };
(APIService.batchGetCollab as jest.Mock).mockResolvedValue(mockResponse);
(APIService.getPublishInfoWithViewId as jest.Mock).mockResolvedValue(mockResponse);
const result1 = batchFetchCollab(workspaceId, params);
const result2 = batchFetchCollab(workspaceId, params);
const result1 = fetchViewInfo(viewId);
const result2 = fetchViewInfo(viewId);
expect(result1).toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
expect(APIService.batchGetCollab).toHaveBeenCalledTimes(1);
expect(APIService.getPublishInfoWithViewId).toHaveBeenCalledTimes(1);
});
it('should fetch view info with different params', async () => {
const viewId = 'view1';
const mockResponse = { data: 'mockData' };
(APIService.getPublishInfoWithViewId as jest.Mock).mockResolvedValue(mockResponse);
const result1 = fetchViewInfo(viewId);
const result2 = fetchViewInfo('view2');
expect(result1).not.toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
await expect(result2).resolves.toEqual(mockResponse);
expect(APIService.getPublishInfoWithViewId).toHaveBeenCalledTimes(2);
});
});
describe('fetchPublishViewMeta', () => {
it('should fetch publish view meta without duplicating requests', async () => {
const namespace = 'namespace1';
const publishName = 'publish1';
const mockResponse = { data: 'mockData' };
(APIService.getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse);
const result1 = fetchPublishViewMeta(namespace, publishName);
const result2 = fetchPublishViewMeta(namespace, publishName);
expect(result1).toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
expect(APIService.getPublishViewMeta).toHaveBeenCalledTimes(1);
});
it('should fetch publish view meta with different params', async () => {
const namespace = 'namespace1';
const publishName = 'publish1';
const mockResponse = { data: 'mockData' };
(APIService.getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse);
const result1 = fetchPublishViewMeta(namespace, publishName);
const result2 = fetchPublishViewMeta(namespace, 'publish2');
expect(result1).not.toBe(result2);
await expect(result1).resolves.toEqual(mockResponse);
await expect(result2).resolves.toEqual(mockResponse);
expect(APIService.getPublishViewMeta).toHaveBeenCalledTimes(2);
});
});
});
@@ -0,0 +1,128 @@
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
import { AFClientService } from '../index';
import { fetchViewInfo } from '@/application/services/js-services/fetch';
import { expect, jest } from '@jest/globals';
import { getBatchCollabs, getPublishView, getPublishViewMeta } from '@/application/services/js-services/cache';
jest.mock('@/application/services/js-services/wasm/client_api', () => {
return {
initAPIService: jest.fn(),
};
});
jest.mock('nanoid', () => {
return {
nanoid: jest.fn().mockReturnValue('12345678'),
};
});
jest.mock('@/application/services/js-services/fetch', () => {
return {
fetchPublishView: jest.fn(),
fetchPublishViewMeta: jest.fn(),
fetchViewInfo: jest.fn(),
};
});
jest.mock('@/application/services/js-services/cache', () => {
return {
getPublishView: jest.fn(),
getPublishViewMeta: jest.fn(),
getBatchCollabs: jest.fn(),
};
});
describe('AFClientService', () => {
let service: AFClientService;
beforeEach(() => {
jest.clearAllMocks();
service = new AFClientService({
cloudConfig: {
baseURL: 'http://localhost:3000',
gotrueURL: 'http://localhost:3000',
wsURL: 'ws://localhost:3000',
},
});
});
it('should get view meta', async () => {
const namespace = 'namespace';
const publishName = 'publishName';
const mockResponse = {
view_id: 'view_id',
publish_name: publishName,
metadata: {
view: {
name: 'viewName',
view_id: 'view_id',
},
child_views: [],
ancestor_views: [],
},
};
// @ts-ignore
(getPublishViewMeta as jest.Mock).mockResolvedValue(mockResponse);
const result = await service.getPublishViewMeta(namespace, publishName);
expect(result).toEqual(mockResponse);
});
it('should get view', async () => {
const namespace = 'namespace';
const publishName = 'publishName';
const mockResponse = {
data: [1, 2, 3],
meta: {
metadata: {
view: {
name: 'viewName',
view_id: 'view_id',
},
child_views: [],
ancestor_views: [],
},
},
};
// @ts-ignore
(getPublishView as jest.Mock).mockResolvedValue(mockResponse);
const result = await service.getPublishView(namespace, publishName);
expect(result).toEqual(mockResponse);
});
it('should get view info', async () => {
const viewId = 'viewId';
const mockResponse = {
namespace: 'namespace',
publish_name: 'publishName',
};
// @ts-ignore
(fetchViewInfo as jest.Mock).mockResolvedValue(mockResponse);
const result = await service.getPublishInfo(viewId);
expect(result).toEqual({
namespace: 'namespace',
publishName: 'publishName',
});
});
it('getPublishDatabaseViewRows', async () => {
const namespace = 'namespace';
const publishName = 'publishName';
const rowIds = ['1', '2', '3'];
const mockResponse = [withTestingYDoc('1'), withTestingYDoc('2'), withTestingYDoc('3')];
// @ts-ignore
(getBatchCollabs as jest.Mock).mockResolvedValue(mockResponse);
const result = await service.getPublishDatabaseViewRows(namespace, publishName, rowIds);
expect(result).toEqual({
rows: expect.any(Object),
destroy: expect.any(Function),
});
});
});
@@ -1,39 +0,0 @@
import { AuthService } from '@/application/services/services.type';
import { ProviderType, SignUpWithEmailPasswordParams } from '@/application/user.type';
import { APIService } from 'src/application/services/js-services/wasm';
import { signInSuccess } from '@/application/services/js-services/session/auth';
import { invalidToken } from 'src/application/services/js-services/session';
import { afterSignInDecorator } from '@/application/services/js-services/decorator';
export class JSAuthService implements AuthService {
constructor() {
// Do nothing
}
getOAuthURL = async (_provider: ProviderType): Promise<string> => {
return Promise.reject('Not implemented');
};
@afterSignInDecorator(signInSuccess)
async signInWithOAuth(_: { uri: string }): Promise<void> {
return Promise.reject('Not implemented');
}
signupWithEmailPassword = async (_params: SignUpWithEmailPasswordParams): Promise<void> => {
return Promise.reject('Not implemented');
};
@afterSignInDecorator(signInSuccess)
async signinWithEmailPassword(email: string, password: string): Promise<void> {
try {
return APIService.signIn(email, password);
} catch (e) {
return Promise.reject(e);
}
}
signOut = async (): Promise<void> => {
invalidToken();
return APIService.logout();
};
}
@@ -0,0 +1,145 @@
import { CollabType } from '@/application/collab.type';
import { withTestingYDoc } from '@/application/slate-yjs/__tests__/withTestingYjsEditor';
import { expect } from '@jest/globals';
import {
collabTypeToDBType,
getPublishView,
getPublishViewMeta,
getBatchCollabs,
} from '@/application/services/js-services/cache';
import { openCollabDB, db } from '@/application/db';
import { StrategyType } from '@/application/services/js-services/cache/types';
jest.mock('@/application/ydoc/apply', () => ({
applyYDoc: jest.fn(),
}));
jest.mock('@/application/db', () => ({
openCollabDB: jest.fn(),
db: {
view_metas: {
get: jest.fn(),
put: jest.fn(),
},
},
}));
const normalDoc = withTestingYDoc('1');
const mockFetcher = jest.fn();
async function runTestWithStrategy(strategy: StrategyType) {
return getPublishView(
mockFetcher,
{
namespace: 'appflowy',
publishName: 'test',
},
strategy
);
}
async function runGetPublishViewMetaWithStrategy(strategy: StrategyType) {
return getPublishViewMeta(
mockFetcher,
{
namespace: 'appflowy',
publishName: 'test',
},
strategy
);
}
describe('Cache functions', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFetcher.mockClear();
(openCollabDB as jest.Mock).mockClear();
});
describe('getPublishView', () => {
it('should call fetcher when no cache found', async () => {
mockFetcher.mockResolvedValue({ data: [1, 2, 3], meta: { metadata: { view: { id: '1' } } } });
(db.view_metas.get as jest.Mock).mockResolvedValue(undefined);
await runTestWithStrategy(StrategyType.CACHE_FIRST);
expect(mockFetcher).toBeCalledTimes(1);
await runTestWithStrategy(StrategyType.CACHE_AND_NETWORK);
expect(mockFetcher).toBeCalledTimes(2);
await expect(runTestWithStrategy(StrategyType.CACHE_ONLY)).rejects.toThrow('No cache found');
});
it('should call fetcher when cache is invalid or strategy is CACHE_AND_NETWORK', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' });
mockFetcher.mockResolvedValue({ data: [1, 2, 3], meta: { metadata: { view: { id: '1' } } } });
await runTestWithStrategy(StrategyType.CACHE_ONLY);
expect(openCollabDB).toBeCalledTimes(1);
await runTestWithStrategy(StrategyType.CACHE_FIRST);
expect(openCollabDB).toBeCalledTimes(2);
expect(mockFetcher).toBeCalledTimes(0);
await runTestWithStrategy(StrategyType.CACHE_AND_NETWORK);
expect(openCollabDB).toBeCalledTimes(3);
expect(mockFetcher).toBeCalledTimes(1);
});
});
describe('getPublishViewMeta', () => {
it('should call fetcher when no cache found', async () => {
mockFetcher.mockResolvedValue({ metadata: { view: { id: '1' }, child_views: [], ancestor_views: [] } });
(db.view_metas.get as jest.Mock).mockResolvedValue(undefined);
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_FIRST);
expect(mockFetcher).toBeCalledTimes(1);
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_AND_NETWORK);
expect(mockFetcher).toBeCalledTimes(2);
await expect(runGetPublishViewMetaWithStrategy(StrategyType.CACHE_ONLY)).rejects.toThrow('No cache found');
});
it('should call fetcher when cache is invalid or strategy is CACHE_AND_NETWORK', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' });
mockFetcher.mockResolvedValue({ metadata: { view: { id: '1' }, child_views: [], ancestor_views: [] } });
const meta = await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_ONLY);
expect(openCollabDB).toBeCalledTimes(0);
expect(meta).toBeDefined();
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_FIRST);
expect(openCollabDB).toBeCalledTimes(0);
expect(mockFetcher).toBeCalledTimes(0);
await runGetPublishViewMetaWithStrategy(StrategyType.CACHE_AND_NETWORK);
expect(openCollabDB).toBeCalledTimes(0);
expect(mockFetcher).toBeCalledTimes(1);
});
});
describe('getBatchCollabs', () => {
it('should return empty array when no cache found', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(undefined);
const collabs = await getBatchCollabs(['1', '2', '3']);
expect(collabs).toEqual([]);
});
it('should return collabs when cache found', async () => {
(openCollabDB as jest.Mock).mockResolvedValue(normalDoc);
(db.view_metas.get as jest.Mock).mockResolvedValue({ view_id: '1' });
const collabs = await getBatchCollabs(['1', '2', '3']);
expect(collabs).toEqual([normalDoc, normalDoc, normalDoc]);
});
});
});
describe('collabTypeToDBType', () => {
it('should return correct DB type', () => {
expect(collabTypeToDBType(CollabType.Document)).toBe('document');
expect(collabTypeToDBType(CollabType.Folder)).toBe('folder');
expect(collabTypeToDBType(CollabType.Database)).toBe('database');
expect(collabTypeToDBType(CollabType.WorkspaceDatabase)).toBe('databases');
expect(collabTypeToDBType(CollabType.DatabaseRow)).toBe('database_row');
expect(collabTypeToDBType(CollabType.UserAwareness)).toBe('user_awareness');
expect(collabTypeToDBType(CollabType.Empty)).toBe('');
});
});
@@ -1,7 +1,14 @@
import { CollabType, YDoc, YjsEditorKey, YSharedRoot } from '@/application/collab.type';
import {
CollabType,
PublishViewInfo,
PublishViewMetaData,
YDoc,
YjsEditorKey,
YSharedRoot,
} from '@/application/collab.type';
import { applyYDoc } from '@/application/ydoc/apply';
import { getCollabDBName, openCollabDB } from './db';
import { Fetcher, StrategyType } from './types';
import { closeCollabDB, db, openCollabDB } from '@/application/db';
import { Fetcher, StrategyType } from '@/application/services/js-services/cache/types';
export function collabTypeToDBType(type: CollabType) {
switch (type) {
@@ -32,30 +39,40 @@ const collabSharedRootKeyMap = {
[CollabType.Empty]: YjsEditorKey.empty,
};
export function hasCache(doc: YDoc, type: CollabType) {
export function hasCollabCache(doc: YDoc) {
const data = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
return data.has(collabSharedRootKeyMap[type] as string);
return Object.values(collabSharedRootKeyMap).some((key) => {
return data.has(key);
});
}
export async function getCollab(
fetcher: Fetcher<{
state: Uint8Array;
}>,
export async function hasViewMetaCache(name: string) {
const data = await db.view_metas.get(name);
return !!data;
}
export async function getPublishViewMeta<
T extends {
view: PublishViewInfo;
child_views: PublishViewInfo[];
ancestor_views: PublishViewInfo[];
}
>(
fetcher: Fetcher<T>,
{
collabId,
collabType,
uuid,
namespace,
publishName,
}: {
uuid?: string;
collabId: string;
collabType: CollabType;
namespace: string;
publishName: string;
},
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK
) {
const name = getCollabDBName(collabId, collabTypeToDBType(collabType), uuid);
const collab = await openCollabDB(name);
const exist = hasCache(collab, collabType);
const name = `${namespace}_${publishName}`;
const exist = await hasViewMetaCache(name);
const meta = await db.view_metas.get(name);
switch (strategy) {
case StrategyType.CACHE_ONLY: {
@@ -63,103 +80,159 @@ export async function getCollab(
throw new Error('No cache found');
}
return collab;
return meta;
}
case StrategyType.CACHE_FIRST: {
if (!exist) {
await revalidateCollab(fetcher, collab);
return revalidatePublishViewMeta(name, fetcher);
}
return collab;
return meta;
}
case StrategyType.CACHE_AND_NETWORK: {
if (!exist) {
await revalidateCollab(fetcher, collab);
return revalidatePublishViewMeta(name, fetcher);
} else {
void revalidateCollab(fetcher, collab);
void revalidatePublishViewMeta(name, fetcher);
}
return collab;
return meta;
}
default: {
await revalidateCollab(fetcher, collab);
return collab;
return revalidatePublishViewMeta(name, fetcher);
}
}
}
async function revalidateCollab(
fetcher: Fetcher<{
state: Uint8Array;
}>,
collab: YDoc
export async function getPublishView<
T extends {
data: number[];
meta: {
view: PublishViewInfo;
child_views: PublishViewInfo[];
ancestor_views: PublishViewInfo[];
};
}
>(
fetcher: Fetcher<T>,
{
namespace,
publishName,
}: {
namespace: string;
publishName: string;
},
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK
) {
const { state } = await fetcher();
const name = `${namespace}_${publishName}`;
const doc = await openCollabDB(name);
const exist = (await hasViewMetaCache(name)) && hasCollabCache(doc);
switch (strategy) {
case StrategyType.CACHE_ONLY: {
if (!exist) {
throw new Error('No cache found');
}
break;
}
case StrategyType.CACHE_FIRST: {
if (!exist) {
await revalidatePublishView(name, fetcher, doc);
}
break;
}
case StrategyType.CACHE_AND_NETWORK: {
if (!exist) {
await revalidatePublishView(name, fetcher, doc);
} else {
void revalidatePublishView(name, fetcher, doc);
}
break;
}
default: {
await revalidatePublishView(name, fetcher, doc);
break;
}
}
return doc;
}
export async function revalidatePublishViewMeta<
T extends {
view: PublishViewInfo;
child_views: PublishViewInfo[];
ancestor_views: PublishViewInfo[];
}
>(name: string, fetcher: Fetcher<T>) {
const { view, child_views, ancestor_views } = await fetcher();
await db.view_metas.put(
{
publish_name: name,
...view,
child_views: child_views,
ancestor_views: ancestor_views,
},
name
);
return db.view_metas.get(name);
}
export async function revalidatePublishView<
T extends {
data: number[];
rows?: Record<string, number[]>;
meta: PublishViewMetaData;
}
>(name: string, fetcher: Fetcher<T>, collab: YDoc) {
const { data, meta, rows } = await fetcher();
await db.view_metas.put(
{
publish_name: name,
...meta.view,
child_views: meta.child_views,
ancestor_views: meta.ancestor_views,
},
name
);
if (rows) {
for (const [key, value] of Object.entries(rows)) {
const row = await openCollabDB(`${name}_${key}`);
applyYDoc(row, new Uint8Array(value));
}
}
const state = new Uint8Array(data);
applyYDoc(collab, state);
}
export async function batchCollab(
batchFetcher: Fetcher<Record<string, number[]>>,
collabs: {
collabId: string;
collabType: CollabType;
uuid?: string;
}[],
strategy: StrategyType = StrategyType.CACHE_AND_NETWORK,
itemCallback?: (id: string, doc: YDoc) => void
) {
const collabMap = new Map<string, YDoc>();
export async function getBatchCollabs(names: string[]) {
const collabs = await Promise.all(names.map((name) => openCollabDB(name)));
for (const { collabId, collabType, uuid } of collabs) {
const name = getCollabDBName(collabId, collabTypeToDBType(collabType), uuid);
const collab = await openCollabDB(name);
const exist = hasCache(collab, collabType);
return collabs;
}
collabMap.set(collabId, collab);
if (exist) {
itemCallback?.(collabId, collab);
}
}
export async function deleteViewMeta(name: string) {
await db.view_metas.delete(name);
}
const notCacheIds = collabs.filter(({ collabId, collabType }) => {
const id = collabMap.get(collabId);
if (!id) return false;
return !hasCache(id, collabType);
});
if (strategy === StrategyType.CACHE_ONLY) {
if (notCacheIds.length > 0) {
throw new Error('No cache found');
}
return;
}
if (strategy === StrategyType.CACHE_FIRST && notCacheIds.length === 0) {
return;
}
const states = await batchFetcher();
for (const [collabId, data] of Object.entries(states)) {
const info = collabs.find((item) => item.collabId === collabId);
const collab = collabMap.get(collabId);
if (!info || !collab) {
continue;
}
const state = new Uint8Array(data);
applyYDoc(collab, state);
itemCallback?.(collabId, collab);
}
export async function deleteView(name: string) {
console.log('deleteView', name);
await deleteViewMeta(name);
await closeCollabDB(name);
}
@@ -1,157 +0,0 @@
import { CollabType, YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { batchCollab, getCollab } from '@/application/services/js-services/cache';
import { StrategyType } from '@/application/services/js-services/cache/types';
import { batchFetchCollab, fetchCollab } from '@/application/services/js-services/fetch';
import { getCurrentWorkspace } from 'src/application/services/js-services/session';
import { DatabaseService } from '@/application/services/services.type';
import * as Y from 'yjs';
export class JSDatabaseService implements DatabaseService {
private loadedDatabaseId: Set<string> = new Set();
private loadedWorkspaceId: Set<string> = new Set();
private cacheDatabaseRowDocMap: Map<string, Y.Doc> = new Map();
constructor() {
//
}
currentWorkspace() {
return getCurrentWorkspace();
}
async getWorkspaceDatabases(): Promise<{ views: string[]; database_id: string }[]> {
const workspace = await this.currentWorkspace();
if (!workspace) {
throw new Error('Workspace database not found');
}
const isLoaded = this.loadedWorkspaceId.has(workspace.id);
const workspaceDatabase = await getCollab(
() => {
return fetchCollab(workspace.id, workspace.workspaceDatabaseId, CollabType.WorkspaceDatabase);
},
{
collabId: workspace.workspaceDatabaseId,
collabType: CollabType.WorkspaceDatabase,
},
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
);
if (!isLoaded) {
this.loadedWorkspaceId.add(workspace.id);
}
return workspaceDatabase.getMap(YjsEditorKey.data_section).get(YjsEditorKey.workspace_database).toJSON() as {
views: string[];
database_id: string;
}[];
}
async openDatabase(databaseId: string): Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
}> {
const workspace = await this.currentWorkspace();
if (!workspace) {
throw new Error('Workspace database not found');
}
const workspaceId = workspace.id;
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();
const databaseDoc = await getCollab(
() => {
return fetchCollab(workspaceId, databaseId, CollabType.Database);
},
{
collabId: databaseId,
collabType: CollabType.Database,
},
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
);
if (!isLoaded) this.loadedDatabaseId.add(databaseId);
const database = databaseDoc.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase;
const viewId = database.get(YjsDatabaseKey.metas)?.get(YjsDatabaseKey.iid)?.toString();
const rowOrders = database.get(YjsDatabaseKey.views)?.get(viewId)?.get(YjsDatabaseKey.row_orders);
const rowOrdersIds = rowOrders.toJSON() as {
id: string;
}[];
if (!rowOrdersIds) {
throw new Error('Database rows not found');
}
const rowsParams = rowOrdersIds.map((item) => ({
collabId: item.id,
collabType: CollabType.DatabaseRow,
}));
void batchCollab(
() => {
return batchFetchCollab(workspaceId, rowsParams);
},
rowsParams,
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK,
(id: string, doc: YDoc) => {
if (!rowsFolder.has(id)) {
rowsFolder.set(id, doc);
}
}
);
// Update rows if there are new rows added after the database has been loaded
rowOrders?.observe((event) => {
if (event.changes.added.size > 0) {
const rowIds = rowOrders.toJSON() as {
id: string;
}[];
const params = rowIds.map((item) => ({
collabId: item.id,
collabType: CollabType.DatabaseRow,
}));
void batchCollab(
() => {
return batchFetchCollab(workspaceId, params);
},
params,
StrategyType.CACHE_AND_NETWORK,
(id: string, doc: YDoc) => {
if (!rowsFolder.has(id)) {
rowsFolder.set(id, doc);
}
}
);
}
});
return {
databaseDoc,
rows: rowsFolder,
};
}
async closeDatabase(databaseId: string) {
this.cacheDatabaseRowDocMap.delete(databaseId);
}
}
@@ -1,60 +0,0 @@
/**
* @description:
* * This is a decorator that can be used to read data from storage and fetch data from the server.
* * If the data is already in storage, it will return the data from storage and fetch the data from the server in the background.
*
* @param getStorage A function that returns the data from storage. eg. `() => Promise<T | undefined>`
*
* @param setStorage A function that saves the data to storage. eg. `(data: T) => Promise<void>`
*
* @param fetchFunction A function that fetches the data from the server. eg. `(params: P) => Promise<T | undefined>`
*
* @returns: A function that returns the data from storage and fetches the data from the server in the background.
*/
export function asyncDataDecorator<P, T>(
getStorage: () => Promise<T | undefined>,
setStorage: (data: T) => Promise<void>,
fetchFunction: (params: P) => Promise<T | undefined>
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
async function fetchData(params: P) {
const data = await fetchFunction(params);
if (!data) return;
await setStorage(data);
return data;
}
const originalMethod = descriptor.value;
descriptor.value = async function (params: P) {
const data = await getStorage();
await originalMethod.apply(this, [params]);
if (data) {
void fetchData(params);
return data;
} else {
return fetchData(params);
}
};
return descriptor;
};
}
export function afterSignInDecorator(successCallback: () => Promise<void>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
descriptor.value = async function (...args: any[]) {
await originalMethod.apply(this, args);
await successCallback();
};
return descriptor;
};
}
@@ -1,47 +0,0 @@
import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type';
import { getCollab } from '@/application/services/js-services/cache';
import { StrategyType } from '@/application/services/js-services/cache/types';
import { fetchCollab } from '@/application/services/js-services/fetch';
import { getCurrentWorkspace } from 'src/application/services/js-services/session';
import { DocumentService } from '@/application/services/services.type';
export class JSDocumentService implements DocumentService {
private loaded: Set<string> = new Set();
constructor() {
//
}
async openDocument(docId: string): Promise<YDoc> {
const workspace = await getCurrentWorkspace();
if (!workspace) {
throw new Error('Workspace database not found');
}
const isLoaded = this.loaded.has(docId);
const doc = await getCollab(
() => {
return fetchCollab(workspace.id, docId, CollabType.Document);
},
{
collabId: docId,
collabType: CollabType.Document,
},
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
);
if (!isLoaded) this.loaded.add(docId);
const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => {
if (origin === CollabOrigin.LocalSync) {
// Send the update to the server
console.log('update', update);
}
};
doc.on('update', handleUpdate);
return doc;
}
}
@@ -1,4 +1,3 @@
import { CollabType } from '@/application/collab.type';
import { APIService } from '@/application/services/js-services/wasm';
const pendingRequests = new Map();
@@ -31,36 +30,20 @@ function fetchWithDeduplication<Req, Res>(url: string, params: Req, fetchFunctio
return fetchPromise;
}
/**
* Fetch collab
* @param workspaceId
* @param id
* @param type [CollabType]
*/
export function fetchCollab(workspaceId: string, id: string, type: CollabType) {
const fetchFunction = () => APIService.getCollab(workspaceId, id, type);
export function fetchPublishView(namespace: string, publishName: string) {
const fetchFunction = () => APIService.getPublishView(namespace, publishName);
return fetchWithDeduplication(`fetchCollab_${workspaceId}`, { id, type }, fetchFunction);
return fetchWithDeduplication(`fetchPublishView_${namespace}`, { publishName }, fetchFunction);
}
/**
* Batch fetch collab
* Usage:
* // load database rows
* const rows = await batchFetchCollab(workspaceId, databaseRows.map((row) => ({ collabId: row.id, collabType: CollabType.DatabaseRow })));
*
* @param workspaceId
* @param params [{ collabId: string; collabType: CollabType }]
*/
export function batchFetchCollab(workspaceId: string, params: { collabId: string; collabType: CollabType }[]) {
const fetchFunction = () =>
APIService.batchGetCollab(
workspaceId,
params.map(({ collabId, collabType }) => ({
object_id: collabId,
collab_type: collabType,
}))
);
export function fetchViewInfo(viewId: string) {
const fetchFunction = () => APIService.getPublishInfoWithViewId(viewId);
return fetchWithDeduplication(`batchFetchCollab_${workspaceId}`, params, fetchFunction);
return fetchWithDeduplication(`fetchViewInfo`, { viewId }, fetchFunction);
}
export function fetchPublishViewMeta(namespace: string, publishName: string) {
const fetchFunction = () => APIService.getPublishViewMeta(namespace, publishName);
return fetchWithDeduplication(`fetchPublishViewMeta_${namespace}`, { publishName }, fetchFunction);
}
@@ -1,39 +0,0 @@
import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type';
import { getCollab } from '@/application/services/js-services/cache';
import { StrategyType } from '@/application/services/js-services/cache/types';
import { fetchCollab } from '@/application/services/js-services/fetch';
import { FolderService } from '@/application/services/services.type';
export class JSFolderService implements FolderService {
private loaded: Set<string> = new Set();
constructor() {
//
}
async openWorkspace(workspaceId: string): Promise<YDoc> {
const isLoaded = this.loaded.has(workspaceId);
const doc = await getCollab(
() => {
return fetchCollab(workspaceId, workspaceId, CollabType.Folder);
},
{
collabId: workspaceId,
collabType: CollabType.Folder,
},
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
);
if (!isLoaded) this.loaded.add(workspaceId);
const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => {
if (origin === CollabOrigin.LocalSync) {
// Send the update to the server
console.log('update', update);
}
};
doc.on('update', handleUpdate);
return doc;
}
}
@@ -1,42 +1,34 @@
import { JSDatabaseService } from '@/application/services/js-services/database.service';
import { YDoc } from '@/application/collab.type';
import {
AFService,
AFServiceConfig,
AuthService,
DatabaseService,
DocumentService,
FolderService,
UserService,
} from '@/application/services/services.type';
import { JSUserService } from '@/application/services/js-services/user.service';
import { JSAuthService } from '@/application/services/js-services/auth.service';
import { JSFolderService } from '@/application/services/js-services/folder.service';
import { JSDocumentService } from '@/application/services/js-services/document.service';
deleteView,
getBatchCollabs,
getPublishView,
getPublishViewMeta,
hasViewMetaCache,
} from '@/application/services/js-services/cache';
import { StrategyType } from '@/application/services/js-services/cache/types';
import { fetchPublishView, fetchPublishViewMeta, fetchViewInfo } from '@/application/services/js-services/fetch';
import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { nanoid } from 'nanoid';
import { initAPIService } from '@/application/services/js-services/wasm/client_api';
import * as Y from 'yjs';
export class AFClientService implements AFService {
authService: AuthService;
userService: UserService;
documentService: DocumentService;
folderService: FolderService;
databaseService: DatabaseService;
private deviceId: string = nanoid(8);
private clientId: string = 'web';
getDeviceID = (): string => {
return this.deviceId;
};
private publishViewLoaded: Set<string> = new Set();
getClientID = (): string => {
return this.clientId;
};
private publishViewInfo: Map<
string,
{
namespace: string;
publishName: string;
}
> = new Map();
private cacheDatabaseRowDocMap: Map<string, Y.Doc> = new Map();
constructor(config: AFServiceConfig) {
initAPIService({
@@ -44,11 +36,118 @@ export class AFClientService implements AFService {
deviceId: this.deviceId,
clientId: this.clientId,
});
}
this.authService = new JSAuthService();
this.userService = new JSUserService();
this.documentService = new JSDocumentService();
this.folderService = new JSFolderService();
this.databaseService = new JSDatabaseService();
async getPublishViewMeta(namespace: string, publishName: string) {
const viewMeta = await getPublishViewMeta(
() => {
return fetchPublishViewMeta(namespace, publishName);
},
{
namespace,
publishName,
},
StrategyType.CACHE_AND_NETWORK
);
if (!viewMeta) {
return Promise.reject(new Error('View has not been published yet'));
}
return viewMeta;
}
async getPublishView(namespace: string, publishName: string) {
const name = `${namespace}_${publishName}`;
const isLoaded = this.publishViewLoaded.has(name);
const doc = await getPublishView(
async () => {
try {
return await fetchPublishView(namespace, publishName);
} catch (e) {
void (async () => {
if (await hasViewMetaCache(name)) {
this.publishViewLoaded.delete(name);
void deleteView(name);
window.location.reload();
}
})();
return Promise.reject(e);
}
},
{
namespace,
publishName,
},
isLoaded ? StrategyType.CACHE_FIRST : StrategyType.CACHE_AND_NETWORK
);
if (!isLoaded) {
this.publishViewLoaded.add(name);
}
return doc;
}
async getPublishDatabaseViewRows(namespace: string, publishName: string, rowIds: string[]) {
const name = `${namespace}_${publishName}`;
if (!this.publishViewLoaded.has(name)) {
await this.getPublishView(namespace, publishName);
}
const rootRowsDoc =
this.cacheDatabaseRowDocMap.get(name) ??
new Y.Doc({
guid: name,
});
if (!this.cacheDatabaseRowDocMap.has(name)) {
this.cacheDatabaseRowDocMap.set(name, rootRowsDoc);
}
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const docs = await getBatchCollabs(rowIds);
docs.forEach((doc, index) => {
rowsFolder.set(rowIds[index], doc);
});
return {
rows: rowsFolder,
destroy: () => {
this.cacheDatabaseRowDocMap.delete(name);
rootRowsDoc.destroy();
},
};
}
async getPublishInfo(viewId: string) {
if (this.publishViewInfo.has(viewId)) {
return this.publishViewInfo.get(viewId) as {
namespace: string;
publishName: string;
};
}
const info = await fetchViewInfo(viewId);
const namespace = info.namespace;
if (!namespace) {
return Promise.reject(new Error('View not found'));
}
const data = {
namespace,
publishName: info.publish_name,
};
this.publishViewInfo.set(viewId, data);
return data;
}
}
@@ -1,3 +0,0 @@
export async function signInSuccess() {
// Do nothing
}
@@ -1,3 +0,0 @@
export * from './token';
export * from './user';
export * from './auth';
@@ -1,37 +0,0 @@
import { notify } from '@/components/_shared/notify';
const tokenKey = 'token';
export function readTokenStr() {
return sessionStorage.getItem(tokenKey);
}
export function getAuthInfo() {
const token = readTokenStr() || '';
try {
const info = JSON.parse(token);
return {
uuid: info.user.id,
access_token: info.access_token,
email: info.user.email,
};
} catch (e) {
return;
}
}
export function writeToken(token: string) {
if (!token) {
invalidToken();
return;
}
sessionStorage.setItem(tokenKey, token);
}
export function invalidToken() {
sessionStorage.removeItem(tokenKey);
notify.error('Invalid token, please login again');
}
@@ -1,43 +0,0 @@
import { UserProfile, UserWorkspace, Workspace } from '@/application/user.type';
const userKey = 'user';
const workspaceKey = 'workspace';
export async function getSignInUser(): Promise<UserProfile | undefined> {
const userStr = localStorage.getItem(userKey);
try {
return userStr ? JSON.parse(userStr) : undefined;
} catch (e) {
return undefined;
}
}
export async function setSignInUser(profile: UserProfile) {
const userStr = JSON.stringify(profile);
localStorage.setItem(userKey, userStr);
}
export async function getUserWorkspace(): Promise<UserWorkspace | undefined> {
const str = localStorage.getItem(workspaceKey);
try {
return str ? JSON.parse(str) : undefined;
} catch (e) {
return undefined;
}
}
export async function setUserWorkspace(workspace: UserWorkspace) {
const str = JSON.stringify(workspace);
localStorage.setItem(workspaceKey, str);
}
export async function getCurrentWorkspace(): Promise<Workspace | undefined> {
const userProfile = await getSignInUser();
const userWorkspace = await getUserWorkspace();
return userWorkspace?.workspaces.find((workspace) => workspace.id === userProfile?.workspaceId);
}
@@ -1,45 +0,0 @@
import { UserService } from '@/application/services/services.type';
import { UserProfile, UserWorkspace } from '@/application/user.type';
import { APIService } from 'src/application/services/js-services/wasm';
import {
getAuthInfo,
getSignInUser,
getUserWorkspace,
invalidToken,
setSignInUser,
setUserWorkspace,
} from 'src/application/services/js-services/session';
import { asyncDataDecorator } from '@/application/services/js-services/decorator';
async function getUser() {
try {
const user = await APIService.getUser();
return user;
} catch (e) {
console.error(e);
invalidToken();
}
}
export class JSUserService implements UserService {
@asyncDataDecorator<void, UserProfile>(getSignInUser, setSignInUser, getUser)
async getUserProfile(): Promise<UserProfile> {
if (!getAuthInfo()) {
return Promise.reject('Not authenticated');
}
await this.getUserWorkspace();
return null!;
}
async checkUser(): Promise<boolean> {
return (await getSignInUser()) !== undefined;
}
@asyncDataDecorator<void, UserWorkspace>(getUserWorkspace, setUserWorkspace, APIService.getUserWorkspace)
async getUserWorkspace(): Promise<UserWorkspace> {
return null!;
}
}
@@ -1,8 +1,6 @@
import { CollabType } from '@/application/collab.type';
import { ClientAPI } from '@appflowyinc/client-api-wasm';
import { UserProfile, UserWorkspace } from '@/application/user.type';
import { AFCloudConfig } from '@/application/services/services.type';
import { invalidToken, readTokenStr, writeToken } from 'src/application/services/js-services/session';
import { PublishViewMetaData } from '@/application/collab.type';
let client: ClientAPI;
@@ -12,8 +10,18 @@ export function initAPIService(
clientId: string;
}
) {
window.refresh_token = writeToken;
window.invalid_token = invalidToken;
if (client) {
return;
}
window.refresh_token = () => {
//
};
window.invalid_token = () => {
// invalidToken();
};
client = ClientAPI.new({
base_url: config.baseURL,
ws_addr: config.wsURL,
@@ -26,96 +34,25 @@ export function initAPIService(
},
});
const token = readTokenStr();
if (token) {
client.restore_token(token);
}
client.subscribe();
}
export function signIn(email: string, password: string) {
return client.login(email, password);
}
export function logout() {
return client.logout();
}
export async function getUser(): Promise<UserProfile> {
try {
const user = await client.get_user();
if (!user) {
throw new Error('No user found');
}
return {
uid: parseInt(user.uid),
uuid: user.uuid || undefined,
email: user.email || undefined,
name: user.name || undefined,
workspaceId: user.latest_workspace_id,
iconUrl: user.icon_url || undefined,
};
} catch (e) {
return Promise.reject(e);
}
}
export async function getCollab(workspaceId: string, object_id: string, collabType: CollabType) {
const res = await client.get_collab({
workspace_id: workspaceId,
object_id: object_id,
collab_type: Number(collabType) as 0 | 1 | 2 | 3 | 4 | 5,
});
const state = new Uint8Array(res.doc_state);
export async function getPublishView(publishNamespace: string, publishName: string) {
const data = await client.get_publish_view(publishNamespace, publishName);
return {
state,
data: data.data,
meta: JSON.parse(data.meta.data) as PublishViewMetaData,
};
}
export async function batchGetCollab(
workspaceId: string,
params: {
object_id: string;
collab_type: CollabType;
}[]
) {
const res = (await client.batch_get_collab(
workspaceId,
params.map((param) => ({
object_id: param.object_id,
collab_type: Number(param.collab_type) as 0 | 1 | 2 | 3 | 4 | 5,
}))
)) as unknown as Map<string, { doc_state: number[] }>;
const result: Record<string, number[]> = {};
res.forEach((value, key) => {
result[key] = value.doc_state;
});
return result;
export async function getPublishInfoWithViewId(viewId: string) {
return client.get_publish_info(viewId);
}
export async function getUserWorkspace(): Promise<UserWorkspace> {
const res = await client.get_user_workspace();
export async function getPublishViewMeta(publishNamespace: string, publishName: string) {
const data = await client.get_publish_view_meta(publishNamespace, publishName);
const metadata = JSON.parse(data.data) as PublishViewMetaData;
return {
visitingWorkspaceId: res.visiting_workspace_id,
workspaces: res.workspaces.map((workspace) => ({
id: workspace.workspace_id,
name: workspace.workspace_name,
icon: workspace.icon,
owner: {
id: Number(workspace.owner_uid),
name: workspace.owner_name,
},
type: workspace.workspace_type,
workspaceDatabaseId: workspace.database_storage_id,
})),
};
return metadata;
}
@@ -1,16 +1,8 @@
import { YDoc } from '@/application/collab.type';
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
import { ViewMeta } from '@/application/db/tables/view_metas';
import * as Y from 'yjs';
export interface AFService {
getDeviceID: () => string;
getClientID: () => string;
authService: AuthService;
userService: UserService;
documentService: DocumentService;
folderService: FolderService;
databaseService: DatabaseService;
}
export type AFService = PublishService;
export interface AFServiceConfig {
cloudConfig: AFCloudConfig;
@@ -22,35 +14,16 @@ export interface AFCloudConfig {
wsURL: string;
}
export interface AuthService {
getOAuthURL: (provider: ProviderType) => Promise<string>;
signInWithOAuth: (params: { uri: string }) => Promise<void>;
signupWithEmailPassword: (params: SignUpWithEmailPasswordParams) => Promise<void>;
signinWithEmailPassword: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
}
export interface DocumentService {
openDocument: (docId: string) => Promise<YDoc>;
}
export interface DatabaseService {
getWorkspaceDatabases: () => Promise<{ views: string[]; database_id: string }[]>;
openDatabase: (
databaseId: string,
rowIds?: string[]
export interface PublishService {
getPublishViewMeta: (namespace: string, publishName: string) => Promise<ViewMeta>;
getPublishView: (namespace: string, publishName: string) => Promise<YDoc>;
getPublishInfo: (viewId: string) => Promise<{ namespace: string; publishName: string }>;
getPublishDatabaseViewRows: (
namespace: string,
publishName: string,
rowIds: string[]
) => Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
destroy: () => void;
}>;
closeDatabase: (databaseId: string) => Promise<void>;
}
export interface UserService {
getUserProfile: () => Promise<UserProfile | null>;
checkUser: () => Promise<boolean>;
}
export interface FolderService {
openWorkspace: (workspaceId: string) => Promise<YDoc>;
}
@@ -1,114 +0,0 @@
import { AFCloudConfig, AuthService } from '@/application/services/services.type';
import {
AuthenticatorPB,
OauthProviderPB,
OauthSignInPB,
SignInPayloadPB,
SignUpPayloadPB,
UserEventGetOauthURLWithProvider,
UserEventOauthSignIn,
UserEventSignInWithEmailPassword,
UserEventSignOut,
UserEventSignUp,
UserProfilePB,
} from './backend/events/flowy-user';
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
export class TauriAuthService implements AuthService {
constructor (private cloudConfig: AFCloudConfig, private clientConfig: {
deviceId: string;
clientId: string;
}) {}
getDeviceID = (): string => {
return this.clientConfig.deviceId;
};
getOAuthURL = async (provider: ProviderType): Promise<string> => {
const providerDataRes = await UserEventGetOauthURLWithProvider(
OauthProviderPB.fromObject({
provider: provider as number,
}),
);
if (!providerDataRes.ok) {
throw new Error(providerDataRes.val.msg);
}
const providerData = providerDataRes.val;
return providerData.oauth_url;
};
signInWithOAuth = async ({ uri }: { uri: string }): Promise<void> => {
const payload = OauthSignInPB.fromObject({
authenticator: AuthenticatorPB.AppFlowyCloud,
map: {
sign_in_url: uri,
device_id: this.getDeviceID(),
},
});
const res = await UserEventOauthSignIn(payload);
if (!res.ok) {
throw new Error(res.val.msg);
}
return;
};
signinWithEmailPassword = async (email: string, password: string): Promise<void> => {
const payload = SignInPayloadPB.fromObject({
email,
password,
});
const res = await UserEventSignInWithEmailPassword(payload);
if (!res.ok) {
return Promise.reject(res.val.msg);
}
return;
};
signupWithEmailPassword = async (params: SignUpWithEmailPasswordParams): Promise<void> => {
const payload = SignUpPayloadPB.fromObject({
name: params.name,
email: params.email,
password: params.password,
device_id: this.getDeviceID(),
});
const res = await UserEventSignUp(payload);
if (!res.ok) {
return Promise.reject(res.val.msg);
}
return;
};
signOut = async () => {
const res = await UserEventSignOut();
if (!res.ok) {
return Promise.reject(res.val.msg);
}
return;
};
}
export function parseUserProfileFrom (userPB: UserProfilePB): UserProfile {
const user = userPB.toObject();
return {
uid: user.id as number,
email: user.email,
name: user.name,
iconUrl: user.icon_url,
workspaceId: user.workspace_id,
};
}
@@ -1,24 +0,0 @@
import { YDoc } from '@/application/collab.type';
import { DatabaseService } from '@/application/services/services.type';
import * as Y from 'yjs';
export class TauriDatabaseService implements DatabaseService {
constructor() {
//
}
async getWorkspaceDatabases(): Promise<{ views: string[]; database_id: string }[]> {
return Promise.reject('Not implemented');
}
async closeDatabase(_databaseId: string) {
return Promise.reject('Not implemented');
}
async openDatabase(_viewId: string): Promise<{
databaseDoc: YDoc;
rows: Y.Map<YDoc>;
}> {
return Promise.reject('Not implemented');
}
}
@@ -1,8 +0,0 @@
import { DocumentService } from '@/application/services/services.type';
import * as Y from 'yjs';
export class TauriDocumentService implements DocumentService {
async openDocument(_id: string): Promise<Y.Doc> {
return Promise.reject('Not implemented');
}
}
@@ -1,12 +0,0 @@
import { YDoc } from '@/application/collab.type';
import { FolderService } from '@/application/services/services.type';
export class TauriFolderService implements FolderService {
constructor() {
//
}
async openWorkspace(_workspaceId: string): Promise<YDoc> {
return Promise.reject('Not implemented');
}
}
@@ -1,50 +1,24 @@
import {
AFService,
AFServiceConfig,
AuthService,
DatabaseService,
DocumentService,
FolderService,
UserService,
} from '@/application/services/services.type';
import { TauriAuthService } from '@/application/services/tauri-services/auth.service';
import { TauriDatabaseService } from '@/application/services/tauri-services/database.service';
import { TauriFolderService } from '@/application/services/tauri-services/folder.service';
import { TauriUserService } from '@/application/services/tauri-services/user.service';
import { TauriDocumentService } from '@/application/services/tauri-services/document.service';
import { AFService } from '@/application/services/services.type';
import { nanoid } from 'nanoid';
export class AFClientService implements AFService {
authService: AuthService;
userService: UserService;
documentService: DocumentService;
folderService: FolderService;
databaseService: DatabaseService;
private deviceId: string = nanoid(8);
private clientId: string = 'web';
private clientId: string = 'tauri';
getDeviceID = (): string => {
return this.deviceId;
};
async getPublishView(_namespace: string, _publishName: string) {
return Promise.reject('Method not implemented');
}
getClientID = (): string => {
return this.clientId;
};
async getPublishInfo(_viewId: string) {
return Promise.reject('Method not implemented');
}
constructor(config: AFServiceConfig) {
this.authService = new TauriAuthService(config.cloudConfig, {
deviceId: this.deviceId,
clientId: this.clientId,
});
this.userService = new TauriUserService();
this.documentService = new TauriDocumentService();
this.folderService = new TauriFolderService();
this.databaseService = new TauriDatabaseService();
async getPublishViewMeta(_namespace: string, _publishName: string) {
return Promise.reject('Method not implemented');
}
async getPublishDatabaseViewRows(_namespace: string, _publishName: string, _rowIds: string[]) {
return Promise.reject('Method not implemented');
}
}
@@ -1,20 +0,0 @@
import { UserService } from '@/application/services/services.type';
import { UserProfile } from '@/application/user.type';
import { UserEventGetUserProfile } from './backend/events/flowy-user';
import { parseUserProfileFrom } from '@/application/services/tauri-services/auth.service';
export class TauriUserService implements UserService {
async getUserProfile(): Promise<UserProfile | null> {
const res = await UserEventGetUserProfile();
if (res.ok) {
return parseUserProfileFrom(res.val);
}
return null;
}
async checkUser(): Promise<boolean> {
return Promise.resolve(false);
}
}
@@ -7,7 +7,7 @@ describe('convert yjs data to slate content', () => {
it('should return undefined if root block is not exist', () => {
const doc = new Y.Doc();
expect(() => yDocToSlateContent(doc)).toThrowError();
expect(yDocToSlateContent(doc)).toBeUndefined();
const doc2 = withTestingYDoc('1');
const { blocks, childrenMap, textMap, pageId } = getTestingDocData(doc2);
@@ -75,7 +75,6 @@ export function withYjs<T extends Editor>(
e.children = content.children;
console.log('initializeDocumentContent', doc.getMap(YjsEditorKey.data_section).toJSON(), e.children);
Editor.normalize(editor, { force: true });
};
@@ -97,9 +97,11 @@ export function yDataToSlateContent({
export function yDocToSlateContent(doc: YDoc): Element | undefined {
const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
if (!sharedRoot || sharedRoot.size === 0) return;
const document = sharedRoot.get(YjsEditorKey.document);
const pageId = document.get(YjsEditorKey.page_id) as string;
const blocks = document.get(YjsEditorKey.blocks) as YBlocks;
const meta = document.get(YjsEditorKey.meta) as YMeta;
const childrenMap = meta.get(YjsEditorKey.children_map) as YChildrenMap;
const textMap = meta.get(YjsEditorKey.text_map) as YTextMap;
@@ -1,75 +0,0 @@
export enum Authenticator {
Local = 0,
Supabase = 1,
AppFlowyCloud = 2,
}
export enum EncryptionType {
NoEncryption = 0,
Symmetric = 1,
}
export interface UserProfile {
uid: number;
uuid?: string;
email?: string;
name?: string;
iconUrl?: string;
workspaceId?: string;
}
export interface UserWorkspace {
visitingWorkspaceId: string;
workspaces: Workspace[];
}
export interface Workspace {
id: string;
name: string;
icon: string;
owner: {
id: number;
name: string;
};
type: number;
workspaceDatabaseId: string;
}
export interface SignUpWithEmailPasswordParams {
name: string;
email: string;
password: string;
}
export enum ProviderType {
Apple = 0,
Azure = 1,
Bitbucket = 2,
Discord = 3,
Facebook = 4,
Figma = 5,
Github = 6,
Gitlab = 7,
Google = 8,
Keycloak = 9,
Kakao = 10,
Linkedin = 11,
Notion = 12,
Spotify = 13,
Slack = 14,
Workos = 15,
Twitch = 16,
Twitter = 17,
Email = 18,
Phone = 19,
Zoom = 20,
}
export interface UserSetting {
workspaceId: string;
latestView?: {
id: string;
name: string;
};
hasLatestView: boolean;
}
@@ -0,0 +1,25 @@
<svg width="57" height="14" viewBox="0 0 57 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 1171274570">
<path id="Vector"
d="M8.35547 4.01126H10.1226V4.67123C10.5568 4.21099 11.3679 3.84192 12.2589 3.84192C14.1641 3.84192 15.3938 5.31819 15.3938 7.36065C15.3938 9.46563 13.9488 11.0947 11.7361 11.0947C11.1213 11.0947 10.5204 10.9871 10.1226 10.7569V13.9995H8.35547V4.01126ZM10.1226 6.25258V9.04967C10.5994 9.35708 10.9989 9.44913 11.5824 9.44913C12.8121 9.44913 13.5189 8.55729 13.5189 7.40494C13.5189 6.2986 12.8737 5.49968 11.7057 5.49968C11.1065 5.50142 10.5534 5.77844 10.1226 6.25258Z"
fill="currentColor"/>
<path id="Vector_2"
d="M3.98189 3.88086C1.76836 3.88086 0.324219 5.50996 0.324219 7.61495C0.324219 9.65915 1.55386 11.1345 3.45912 11.1345C4.35009 11.1345 5.16464 10.7655 5.59536 10.3035V10.9652H7.36862V5.16348C6.05561 4.16917 4.60019 3.88086 3.98189 3.88086ZM5.59536 8.72215C5.16117 9.19716 4.61147 9.47418 4.01228 9.47418C2.84516 9.47418 2.19908 8.67526 2.19908 7.56979C2.19908 6.41657 2.90595 5.52473 4.13473 5.52473C4.71916 5.52473 5.11861 5.61765 5.59536 5.92506V8.72215Z"
fill="currentColor"/>
<path id="Vector_3"
d="M16.1484 4.01126H17.9156V4.67123C18.3498 4.21099 19.16 3.84192 20.051 3.84192C21.9571 3.84192 23.1859 5.31819 23.1859 7.36065C23.1859 9.46563 21.7418 11.0947 19.5291 11.0947C18.9143 11.0947 18.3133 10.9871 17.9156 10.7569V13.9995H16.1484V4.01126ZM17.9156 6.25258V9.04967C18.3915 9.35708 18.791 9.44913 19.3754 9.44913C20.6042 9.44913 21.311 8.55729 21.311 7.40494C21.311 6.2986 20.6658 5.49968 19.4978 5.49968C18.8986 5.50142 18.3455 5.77844 17.9156 6.25258Z"
fill="currentColor"/>
<path id="Vector_4"
d="M28.3102 2.02887C27.9904 1.80209 27.6113 1.6738 27.2195 1.6598C26.4206 1.6598 26.0037 2.0749 26.0037 3.02752V4.01141H27.2481V5.67091H26.0055V10.9264H24.2383V2.88945C24.2383 0.968563 25.1605 0.000305176 26.6967 0.000305176C27.4036 0.000305176 28.0497 0.173984 28.5099 0.461422H30.0774V8.29867C30.0774 9.08022 30.2771 9.41976 30.6766 9.41976C30.9692 9.41976 31.1976 9.29732 31.4147 9.14361L31.7829 10.4957C31.3834 10.8187 30.7842 11.0793 29.9593 11.0793C28.8989 11.0793 28.3145 10.434 28.3145 9.0203L28.3102 2.02887Z"
fill="currentColor"/>
<path id="Vector_5"
d="M35.5466 3.84192C37.8357 3.84192 39.3111 5.39374 39.3111 7.46833C39.3111 9.54292 37.8348 11.0947 35.5466 11.0947C33.2584 11.0947 31.7812 9.54292 31.7812 7.46833C31.7812 5.39374 33.2566 3.84192 35.5466 3.84192ZM35.5466 9.45087C36.6373 9.45087 37.4362 8.66932 37.4362 7.46833C37.4362 6.30034 36.6225 5.50142 35.5466 5.50142C34.5045 5.50142 33.6717 6.26995 33.6717 7.46833C33.6717 8.62069 34.4707 9.45087 35.5466 9.45087Z"
fill="currentColor"/>
<path id="Vector_6"
d="M48.0544 10.9565H46.4713L44.995 7.11477C44.8874 6.85425 44.8413 6.56161 44.7641 6.30022C44.7077 6.58869 44.6255 6.87148 44.5183 7.14517L43.0594 10.9565H41.5545L39.3105 4.0094H41.201L42.3074 7.77388C42.3943 8.07652 42.4611 8.38461 42.5071 8.69611C42.5772 8.38422 42.6642 8.07635 42.7676 7.77388L44.0581 4.0094H45.6212L46.9672 7.75999C47.0701 8.0738 47.157 8.39262 47.2277 8.71522C47.2893 8.39305 47.3666 8.05437 47.443 7.71657L48.4573 4.01374H50.2401L48.0544 10.9565Z"
fill="currentColor"/>
<path id="Vector_7"
d="M53.9557 10.9265C52.9414 13.3997 52.2962 13.9997 51.2819 13.9997C50.6514 13.9997 50.1903 13.7531 49.7909 13.4613L50.3596 12.1249C50.5437 12.2325 50.8051 12.3706 51.0822 12.3706C51.5276 12.3706 51.8498 12.0172 52.1425 11.2947L52.2962 10.9265L49.0996 4.01147H51.222L52.8354 7.79159C52.9735 8.12939 53.0656 8.45157 53.1732 8.79024C53.2298 8.4465 53.3119 8.10746 53.419 7.77596L54.6643 4.01147H56.6755L53.9557 10.9265Z"
fill="currentColor"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,6 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Icons/ Arrow / right" opacity="0.5">
<path id="Vector 15" d="M4.5 9.375L7.875 6L4.5 2.625" stroke="currentColor" stroke-width="0.9"
stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 326 B

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.2023 19.4658V4.53479C10.2023 3.11729 9.61505 2.55029 8.15599 2.55029H4.4487C2.98964 2.55029 2.40234 3.11729 2.40234 4.53479V19.4658C2.40234 20.8833 2.98964 21.4503 4.4487 21.4503H8.15599C9.61505 21.4503 10.2023 20.8833 10.2023 19.4658Z"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.6022 13.1778V4.59779C21.6022 3.13529 21.015 2.55029 19.5559 2.55029H15.8486C14.3895 2.55029 13.8022 3.13529 13.8022 4.59779V13.1778C13.8022 14.6403 14.3895 15.2253 15.8486 15.2253H19.5559C21.015 15.2253 21.6022 14.6403 21.6022 13.1778Z"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 811 B

@@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="9" r="2.75" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 160 B

@@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="9" r="2.75" stroke="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 162 B

Loaded 100 of 307 files, more files were not shown because too many files have changed in this diff. Show more