feat: support publish document (#5576)

* feat: support a event for getting encoded collab of document

* feat: support publish view and unpublish views

* feat: publish page to the web

* chore: refacotor share bloc

* feat: call the publish event

* feat: support publish view and unpublish views

* feat: integrate publish api

* feat: integrate unpublish api

* feat: fetch the publish info to show the publish status

* feat: support publish interfaces

* fix: lint error

* fix: modified web server

* fix: some style

* fix: some style

* fix: some style

* fix: some style

* fix: some style

* fix: some style

* fix: some style

* fix: some style

* fix: some style

* fix: update codes

* fix: update codes

* fix: update codes

* fix: update codes

* fix: update codes

* chore: refactor publish bloc

* fix: some style

* fix: some style

* fix: some style

* fix: some style

* fix: some style

* fix: some style

* fix: the name is too long to publish

* chore: change color

* fix: some style

* fix: some style

* feat: refacotor share menu UI

* fix: some style

* fix: lint

* fix: some style

* feat: refacotor export-as

* fix: some style

* chore: refactor share menu colors

* fix: rust ci

* fix: some style

* fix: some style

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: bugs

* fix: rerelease

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: deploy

* fix: og image

* fix: support copy button

* fix: support copy button

* fix: support copy button

* chore: add a params

* feat: use default publish name

* chore: update copy

* feat: show a confirm deletion dialog if the deleted page contains published page

* feat: add copy toast in publish tab

* fix: to 404

fix: to 404

fix: to 404

fix: the error to 404

* feat: unpublish the page auto when moving it to another space

* feat: improve confirm deletion dialog

* feat: show unpublish error

* chore: use beta.appflowy.com

* feat: disable publish in non-apppflowy-cloud user mode

* fix: modified bullted icon style

* fix: the dark mode color

* fix: save the dark mode in local storage

* fix: text color

* chore: make bash script more portable (#5679)

* fix: title longer

* chore: move the files and modified the en

* chore: update deploy.sh

* chore: modified Dockerfile

* chore: modified server.cjs to server.js

* chore: modifed server.js to server.ts

* chore: replace publish url

* chore: remove todo list hover

* chore: show confirm dialog before deleting page

* fix: unpublish the pages before deleting

* fix: table cell bg color

* fix: callout icon

* fix: list number

* fix: emoji

* fix: number icon

* fix: callout icon position

* fix: add margin bottom

* fix: code block

* fix: support scroll for breadcrumbs

* fix: the breadcrumb doesn't update after moving page

* fix: 0705 issues

* fix: update publish status afer deleting page

* chore: add hover effect for visit site button

* fix: remove puiblish url text field enable border color

* chore: update delete page copy

* chore: enable debug category

* fix: only render sidebar if the spaces are ready

* fix: the breadcrumb doesn't update after moving page

* fix: auto code

* fix: add emoji

* fix: add emoji

* fix: favicon

* fix: cypress test

* fix: remove deploy ci

* fix: default url

* chore: revert launch.json

* fix: docker ci

* fix: change favicon

* fix: flutter integration test

* feat: add hover effect to share menu

* chore: add a checkmark if the page has been published

* chore: revert space deletion

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
Co-authored-by: Zack <speed2exe@live.com.sg>
This commit is contained in:
Kilu.He 2024-07-08 13:45:57 +08:00 committed by GitHub
parent 521fffd97c
commit 23c67bcdba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
327 changed files with 6960 additions and 5065 deletions

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

View File

@ -8,7 +8,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Empty', () {
testWidgets('toggle theme mode', (tester) async {
testWidgets('empty test', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.wait(1000);

View File

@ -51,13 +51,14 @@ void main() {
},
);
final shareButton = find.byType(ShareActionList);
final shareButton = find.byType(DocumentShareButton);
final shareButtonState =
tester.state(shareButton) as ShareActionListState;
tester.widget(shareButton) as DocumentShareButton;
final path = await mockSaveFilePath(
p.join(
context.applicationDataDirectory,
'${shareButtonState.name}.md',
'${shareButtonState.view.name}.md',
),
);

View File

@ -1,58 +1,190 @@
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';
const _url = 'https://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));
},
onViewMoveToTrash: (p0) {
add(const DocumentShareEvent.setPublishStatus(false));
},
);
add(const DocumentShareEvent.updatePublishStatus());
},
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));
},
setPublishStatus: (isPublished) {
emit(
state.copyWith(
isPublished: isPublished,
url: isPublished ? state.url : '',
),
);
},
updatePublishStatus: () async {
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,
),
);
});
},
);
});
}
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 +225,41 @@ 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;
const factory DocumentShareEvent.updatePublishStatus() = _UpdatePublishStatus;
const factory DocumentShareEvent.setPublishStatus(bool isPublished) =
_SetPublishStatus;
}
@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: '',
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,284 @@
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/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: [
_buildUnpublishButton(),
const Spacer(),
_buildVisitSiteButton(),
],
),
],
);
}
Widget _buildUnpublishButton() {
return SizedBox(
height: 36,
width: 184,
child: FlowyButton(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: ShareMenuColors.borderColor(context)),
),
radius: BorderRadius.circular(10),
text: FlowyText.regular(
LocaleKeys.shareAction_unPublish.tr(),
textAlign: TextAlign.center,
),
onTap: widget.onUnPublish,
),
);
}
Widget _buildVisitSiteButton() {
return RoundedTextButton(
onPressed: () {
safeLaunchUrl(controller.text);
},
title: LocaleKeys.shareAction_visitSite.tr(),
width: 184,
height: 36,
borderRadius: const BorderRadius.all(Radius.circular(10)),
fillColor: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
);
}
}
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,
),
),
),
);
}
}

View File

@ -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,44 @@ 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: const InnerDocumentShareButton(),
),
),
);
},
),
),
);
@ -75,6 +98,24 @@ class DocumentShareButton extends StatelessWidget {
}
}
class InnerDocumentShareButton extends StatelessWidget {
const InnerDocumentShareButton({super.key});
@override
Widget build(BuildContext context) {
return 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,
);
}
}
class ShareActionList extends StatefulWidget {
const ShareActionList({
super.key,

View File

@ -0,0 +1,186 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
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/application/document_share_bloc.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 'package:flutter_bloc/flutter_bloc.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(
tab: tab,
isSelected: selectedTab == tab,
),
),
];
return TabBar(
indicatorSize: TabBarIndicatorSize.label,
indicator: RoundUnderlineTabIndicator(
width: 68.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 StatefulWidget {
const _Segment({
required this.tab,
required this.isSelected,
});
final bool isSelected;
final ShareMenuTab tab;
@override
State<_Segment> createState() => _SegmentState();
}
class _SegmentState extends State<_Segment> {
bool isHovered = false;
@override
Widget build(BuildContext context) {
Color? textColor = Theme.of(context).hintColor;
if (isHovered) {
textColor = const Color(0xFF00BCF0);
} else if (widget.isSelected) {
textColor = null;
}
Widget child = MouseRegion(
onEnter: (_) => setState(() => isHovered = true),
onExit: (_) => setState(() => isHovered = false),
child: FlowyText(
widget.tab.i18n,
textAlign: TextAlign.center,
color: textColor,
),
);
if (widget.tab == ShareMenuTab.publish) {
final isPublished = context.watch<DocumentShareBloc>().state.isPublished;
// show checkmark icon if published
if (isPublished) {
child = Row(
children: [
const FlowySvg(
FlowySvgs.published_checkmark_s,
blendMode: null,
),
const HSpace(6),
child,
],
);
}
}
return child;
}
}

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy/core/config/kv.dart';
@ -7,6 +8,7 @@ import 'package:appflowy/workspace/application/favorite/favorite_listener.dart';
import 'package:appflowy/workspace/application/recent/cached_recent_service.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-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@ -138,11 +140,18 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
);
},
delete: (e) async {
// unpublish the page and all its child pages if they are published
await _unpublishPage(view);
final result = await ViewBackendService.delete(viewId: view.id);
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,20 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
return null;
}
// unpublish the page and all its child pages
Future<void> _unpublishPage(ViewPB views) async {
final (_, publishedPages) = await ViewBackendService.containPublishedPage(
view,
);
await Future.wait(
publishedPages.map((view) async {
Log.info('unpublishing page: ${view.id}, ${view.name}');
await ViewBackendService.unpublish(view);
}),
);
}
bool _isSameViewIgnoreChildren(ViewPB from, ViewPB to) {
return _hash(from) == _hash(to);
}
@ -430,6 +463,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

View File

@ -291,4 +291,78 @@ 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;
}
static Future<(bool, List<ViewPB>)> containPublishedPage(ViewPB view) async {
final childViews = await ViewBackendService.getAllChildViews(view);
final views = [view, ...childViews];
final List<ViewPB> publishedPages = [];
for (final view in views) {
final publishInfo = await ViewBackendService.getPublishInfo(view);
if (publishInfo.isSuccess) {
publishedPages.add(view);
}
}
return (publishedPages.isNotEmpty, publishedPages);
}
}

View File

@ -15,6 +15,14 @@ class ViewTitleBarBloc extends Bloc<ViewTitleBarEvent, ViewTitleBarState> {
await event.when(
initial: () async {
add(const ViewTitleBarEvent.reload());
viewListener = ViewListener(
viewId: view.id,
)..start(
onViewUpdated: (p0) {
add(const ViewTitleBarEvent.reload());
},
);
},
reload: () async {
final List<ViewPB> ancestors =
@ -30,6 +38,13 @@ class ViewTitleBarBloc extends Bloc<ViewTitleBarEvent, ViewTitleBarState> {
}
final ViewPB view;
late final ViewListener viewListener;
@override
Future<void> close() {
viewListener.stop();
return super.close();
}
}
@freezed

View File

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

View File

@ -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,82 @@ 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: [
Flexible(
child: FlowyText(
widget.title,
fontSize: 14.0,
overflow: TextOverflow.ellipsis,
),
),
const HSpace(6.0),
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,
),
],
),
),
);
}

View File

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

View File

@ -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(),

View File

@ -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,36 @@ 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 (containPublishedPage, _) =
await ViewBackendService.containPublishedPage(
widget.view,
);
if (containPublishedPage && 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 +749,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 +825,7 @@ bool isReferencedDatabaseView(ViewPB view, ViewPB? parentView) {
void _moveViewCrossSection(
BuildContext context,
ViewPB? toSpace,
ViewPB view,
ViewPB? parentView,
FolderSpaceType spaceType,
@ -822,6 +846,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,

View File

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

View File

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

View File

@ -1,14 +1,15 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
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/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
enum ViewActionType {
@ -46,8 +47,8 @@ class ViewAction extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FlowyButton(
onTap: () {
context.read<ViewBloc>().add(type.actionEvent);
onTap: () async {
await _onAction(context);
mutex?.close();
},
text: FlowyText.regular(
@ -63,4 +64,25 @@ class ViewAction extends StatelessWidget {
hoverColor: AFThemeExtension.of(context).lightGreyHover,
);
}
Future<void> _onAction(BuildContext context) async {
if (type == ViewActionType.delete) {
final (containPublishedPage, _) =
await ViewBackendService.containPublishedPage(view);
if (containPublishedPage && context.mounted) {
await showConfirmDeletionDialog(
context: context,
name: 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());
}
} else {
context.read<ViewBloc>().add(type.actionEvent);
}
}
}

View File

@ -37,7 +37,9 @@ class ViewTitleBar extends StatelessWidget {
scrollDirection: Axis.horizontal,
child: SizedBox(
height: 24,
child: Row(children: _buildViewTitles(context, ancestors)),
child: Row(
children: _buildViewTitles(context, ancestors),
),
),
);
},
@ -76,6 +78,7 @@ class ViewTitleBar extends StatelessWidget {
}
final child = FlowyTooltip(
key: ValueKey(view.id),
message: view.name,
child: _ViewTitle(
view: view,

View File

@ -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,
@ -199,7 +204,10 @@ class FlowyTextFieldState extends State<FlowyTextField> {
focusedBorder: OutlineInputBorder(
borderRadius: Corners.s8Border,
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
color: widget.readOnly
? widget.enableBorderColor ??
Theme.of(context).colorScheme.outline
: Theme.of(context).colorScheme.primary,
),
),
errorBorder: OutlineInputBorder(

View File

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

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:

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

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"]

View File

@ -1,284 +1,163 @@
<div align="center">
<h1><code>AppFlowy Web Project</code></h1>
<div>Welcome to the AppFlowy Web Project, a robust and versatile platform designed to bring the innovative features of
AppFlowy to the web. This project uniquely supports running as a desktop application via Tauri, and offers web
interfaces powered by WebAssembly (WASM). Dive into an exceptional development experience with high performance and
extensive capabilities.</div>
<div align="center">
<h1>AppFlowy Web</h1>
</div>
<img src="https://img.shields.io/badge/React-v18.2.0-blue"/>
<img src="https://img.shields.io/badge/TypeScript-v4.9.5-blue"/>
<img src="https://img.shields.io/badge/Nginx-v1.21.6-brightgreen"/>
<img src="https://img.shields.io/badge/Bun-latest-black"/>
<img src="https://img.shields.io/badge/Docker-v20.10.12-blue"/>
</div>
## 🐑 Features
## 🌟 Introduction
- **Cross-Platform Compatibility**: Seamlessly run on desktop environments with Tauri, and on any web browser through
WASM.
- **High Performance**: Leverage the speed and efficiency of WebAssembly for your web interfaces.
- **Tauri Integration**: Build lightweight, secure, and efficient desktop applications.
- **Flexible Development**: Utilize a wide range of AppFlowy's functionalities in your web or desktop projects.
Welcome to the AppFlowy Web project! This project aims to bring the powerful features of AppFlowy to the web. Whether
you're a developer looking to contribute or a user eager to try out the latest features, this guide will help you get
started.
## 🚀 Getting Started
AppFlowy Web is built with the following technologies:
### 🛠️ Prerequisites
- **React**: A JavaScript library for building user interfaces.
- **TypeScript**: A typed superset of JavaScript that compiles to plain JavaScript.
- **Bun**: A fast all-in-one JavaScript runtime.
- **Nginx**: A high-performance web server.
- **Docker**: A platform to develop, ship, and run applications in containers.
Before you begin, ensure you have the following installed:
### Resource Sharing
- Node.js (v14 or later)
- Rust (latest stable version)
- Tauri prerequisites for your operating system
- PNPM (8.5.0)
To maintain consistency across different platforms, the Web project shares i18n translation files and Icons with the
Flutter project. This ensures a unified user experience and reduces duplication of effort in maintaining these
resources.
### 🏗️ Installation
- **i18n Translation Files**: The translation files are shared to provide a consistent localization experience across
both Web and Flutter applications. The path to the translation files is `frontend/resources/translations/`.
#### Clone the Repository
> The translation files are stored in JSON format and contain translations for different languages. The files are
named according to the language code (e.g., `en.json` for English, `es.json` for Spanish, etc.).
```bash
git clone https://github.com/AppFlowy-IO/AppFlowy
```
- **Icons**: The icon set used in the Web project is the same as the one used in the Flutter project, ensuring visual
consistency. The icons are stored in the `frontend/resources/flowy_icons/` directory.
#### 🌐 Install the frontend dependencies:
Let's dive in and get the project up and running! 🚀
```bash
cd frontend/appflowy_web_app
pnpm install
```
## 🛠 Getting Started
#### 🖥️ Desktop Application (Tauri) (Optional)
### Prerequisites
> **Note**: if you want to run the web app in the browser, skip this step
Before you begin, make sure you have the following installed on your system:
- Follow the instructions [here](https://tauri.app/v1/guides/getting-started/prerequisites/) to install Tauri
- [Node.js](https://nodejs.org/) (v18.6.0) 🌳
- [pnpm](https://pnpm.io/) (package manager) 📦
- [Jest](https://jestjs.io/) (testing framework) 🃏
- [Cypress](https://www.cypress.io/) (end-to-end testing) 🧪
##### Windows and Linux Prerequisites
### Clone the Repository
###### Windows only
First, clone the repository to your local machine:
- Install the Duckscript CLI and vcpkg
```bash
git clone https://github.com/AppFlowy-IO/AppFlowy.git
cd frontend/appflowy_web_app
```
```bash
cargo install --force duckscript_cli
vcpkg integrate install
```
### Install Dependencies
###### Linux only
Install the required dependencies using pnpm:
- Install the required dependencies
```bash
## ensure you have pnpm installed, if not run the following command
# npm install -g pnpm@8.5.0
```bash
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
```
pnpm install
```
- **Get error**: failed to run custom build command for librocksdb-sys v6.11.4
### Configure Environment Variables
```bash
sudo apt install clang
```
Create a `.env` file in the root of the project and add the following environment variables:
##### Install Tauri Dependencies
```bash
AF_BASE_URL=http://localhost:8080
AF_GOTRUE_URL=http://localhost:9999
AF_WS_URL=ws://localhost:8080/ws/v1
```
- Install cargo-make
### Start the Development Server
```bash
cargo install --force cargo-make
```
To start the development server, run the following command:
```bash
pnpm run dev
```
- Install AppFlowy dev tools
### 🚀 Building for Production(Optional)
```bash
# install development tools
# make sure you are in the root directory of the project
cd frontend
cargo make appflowy-tauri-deps-tools
```
if you want to run the production build, use the following commands
- Build the service/dependency
```bash
pnpm run build
pnpm run start
```
```bash
# make sure you are in the root directory of the project
cd frontend/appflowy_web_app
mkdir dist
cd src-tauri
cargo build
```
This will start the application in development mode. Open http://localhost:3000 to view it in the browser.
### 🚀 Running the Application
## 🧪 Running Tests
#### 🌐 Web Application
### Unit Tests
- Run the web application
We use **Jest** for running unit tests. To run the tests, use the following command:
```bash
pnpm run dev
```
- Open your browser and navigate to `http://localhost:3000`, You can now interact with the AppFlowy web application
```bash
pnpm run test:unit
```
#### 🖥️ Desktop Application (Tauri)
This will execute all the unit tests in the project and provide a summary of the results. ✅
**Ensure close web application before running the desktop application**
### Components Tests
- Run the desktop application
We use **Cypress** for end-to-end testing. To run the Cypress tests, use the following command:
```bash
pnpm run tauri:dev
```
- The AppFlowy desktop application will open, and you can interact with it
```bash
pnpm run cypress:open
```
### 🛠️ Development
This will open the Cypress Test Runner where you can run your end-to-end tests. 🧪
#### How to add or modify i18n keys
- Modify the i18n files in `frontend/resources/translations/en.json` to add or modify i18n keys
- Run the following command to update the i18n keys in the application
```bash
pnpm run sync:i18n
```
#### How to modify the theme
Don't modify the theme file in `frontend/appflowy_web_app/src/styles/variables` directly)
- Modify the theme file in `frontend/appflowy_web_app/style-dictionary/tokens/base.json( or dark.json or light.json)` to
add or modify theme keys
- Run the following command to update the theme in the application
```bash
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
```bash
# ensure you are in the frontend/appflowy_web_app directory
pnpm run link:client-api $source_path $target_path
# Example
# pnpm run link:client-api ../../../AppFlowy-Cloud/libs/client-api-wasm/pkg ./node_modules/@appflowyinc/client-api-wasm
```
### 📝 About the Project
#### 📁 Directory Structure
- `frontend/appflowy_web_app`: Contains the web application source code
- `frontend/appflowy_web_app/src`: Contains the app entry point and the source code
- `frontend/appflowy_web_app/src/components`: Contains the react components
- `frontend/appflowy_web_app/src/styles`: Contains the styles for the application
- `frontend/appflowy_web_app/src/utils`: Contains the utility functions
- `frontend/appflowy_web_app/src/i18n`: Contains the i18n files
- `frontend/appflowy_web_app/src/assets`: Contains the assets for the application
- `frontend/appflowy_web_app/src/store`: Contains the redux store
- `frontend/appflowy_web_app/src/@types`: Contains the typescript types
- `frontend/appflowy_web_app/src/applications/services`: Contains the services for the application. In vite.config.ts,
we have defined the alias for the services directory for different environments(Tauri/Web)
```typescript
resolve: {
alias: [
// ...
{
find: '$client-services',
replacement: !!process.env.TAURI_PLATFORM
? `${__dirname}/src/application/services/tauri-services`
: `${__dirname}/src/application/services/js-services`,
},
]
}
```
### 📦 Deployment
Use the AppFlowy CI/CD pipeline to deploy the application to the test and production environments.
- Push the changes to the main branch
- Deploy Test Environment
- Automatically, the test environment will be deployed if merged to the main branch or build/test branch
- Deploy Production Environment
- Navigate to the Actions tab
- Click on the workflow and select the Run workflow
- Enter the options
- Click on the Run workflow button
#### 📦 Deployment (Self-Hosted EC2)
##### Pre-requisites
Please ensure you have learned about:
- [Deploy Web application on AWS Cloud using EC2 Instance](https://www.youtube.com/watch?v=gWVIIU1ev0Y)
- [How to Install and Use Rsync Command](https://operavps.com/docs/install-rsync-command-in-linux/)
- [How to Use ssh-keygen to Generate a New SSH Key?](https://www.ssh.com/academy/ssh/keygen)
- [Linux post-installation steps for Docker Engine](https://docs.docker.com/engine/install/linux-postinstall/)
- [Configuring HTTPS servers](https://nginx.org/en/docs/http/configuring_https_servers.html)
And then follow the steps below:
1. Ensure you have the following installed on your server:
- Docker: [Install Docker](https://docs.docker.com/engine/install/)
- Rsync: [Install Rsync](https://operavps.com/docs/install-rsync-command-in-linux/)
2. Create a new user for deploy, and generate an SSH key for the user
```bash
sudo adduser appflowy(or any name)
sudo su - appflowy
mkdir ~/.ssh
chmod 700 ~/.ssh
ssh-keygen -t rsa
chmod 600 ~/.ssh/authorized_keys
# add the user to the docker group, to run docker commands without sudo
sudo usermod -aG docker ${USER}
```
- visit the `~/.ssh/id_rsa` and `~/.ssh/id_rsa.pub` to get the private and public key respectively
- add the public key to the `~/.ssh/authorized_keys` file
- ensure the private key is kept safe
- exit and login back to the server with the new
user: `ssh -i your-existing-key.pem ec2-user@your-instance-public-dns`
3. Clone the AppFlowy repository
4. Set the following secrets in your
repository, have to
know [Using secrets in GitHub Actions](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions)
> Note: Test Environment: prefix the secret with `WEB_TEST_` and Production Environment: prefix the secret with `WEB_`
> for example, `WEB_TEST_SSH_PRIVATE_KEY` and `WEB_SSH_PRIVATE_KEY`
- `SSH_PRIVATE_KEY`: The private key generated in step 2: cat ~/.ssh/id_rsa
- `REMOTE_HOST`: The host of the server: `your-instance-public-dns` or `your-instance-ip`
- `REMOTE_USER`: The user created in step 2: `appflowy`
- `SSL_CERTIFICATE`: The SSL certificate for the
server - [Configuring HTTPS servers](https://nginx.org/en/docs/http/configuring_https_servers.html)
- `SSL_CERTIFICATE_KEY`: The SSL certificate key for the
server - [Configuring HTTPS servers](https://nginx.org/en/docs/http/configuring_https_servers.html)
5. Run the deployment workflow to deploy the application(production or test environment)
> Note: the test server will **automatically** deploy if merged to the main branch or build/test branch
### 🧪 Testing
> We use Cypress for end-to-end testing and component testing - [Cypress](https://www.cypress.io/)
#### 🧪 End-to-End Testing
> to be continued
#### 🧪 Component Testing
Run the following command to run the component tests
Alternatively, to run Cypress tests in the headless mode, use:
```bash
pnpm run test:components
```
Both commands will provide detailed test results and generate a code coverage report.
## 🔄 Development Workflow
### Linting
To maintain code quality, we use **ESLint**. To run the linter and fix any linting errors, use the following command:
```bash
pnpm run lint
```
## 🚀 Production Deployment
Our production deployment process is automated using GitHub Actions. The process involves:
1. **Setting up an AWS EC2 instance**: We use an EC2 instance to host the application.
2. **Installing Docker and Docker Compose**: Docker is installed on the AWS instance.
3. **Configuring SSH Access**: SSH access is set up with a user and password.
4. **Preparing Project Configuration**: We configure `Dockerfile`, `nginx.conf`, and `server.cjs` in the web project.
5. **Using GitHub Actions**: We use the easingthemes/ssh-deploy@main action to deploy the project to the remote server.
The deployment steps include building the Docker image and running the Docker container with the necessary port
mappings:
```bash
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
```
The Web server runs on Bun. For more details about Bun, please refer to the [Bun documentation](https://bun.sh/).

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@ -1,6 +0,0 @@
{
"code": 0,
"data": {
"is_new": false
}
}

View File

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

View File

@ -7,6 +7,9 @@
<title>Components App</title>
</head>
<body id="body">
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js"></script>
<div data-cy-root></div>
</body>
</html>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,193 @@
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';
let favicon = '/appflowy.svg';
try {
if (metaData && metaData.view) {
const view = metaData.view;
const emoji = view.icon.value;
const titleList = [];
if (emoji) {
const emojiCode = emoji.codePointAt(0).toString(16); // Convert emoji to hex code
const baseUrl = 'https://raw.githubusercontent.com/googlefonts/noto-emoji/main/svg/emoji_u';
favicon = `${baseUrl}${emojiCode}.svg`;
}
if (view.name) {
titleList.push(view.name);
titleList.push('|');
}
titleList.push('AppFlowy');
title = titleList.join(' ');
try {
const cover = view.extra ? JSON.parse(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);
$('link[rel="icon"]').attr('href', favicon);
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 {};

View File

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

View File

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

View File

@ -13,7 +13,7 @@
content="AppFlowy is an AI collaborative workspace where you achieve more without losing control of your data"
/>
<meta property="og:image"
content="https://d3uafhn8yrvdfn.cloudfront.net/website/production/_next/static/media/og-image.e347bfb5.png"
content="/_next/static/media/og-image.e347bfb5.png"
/>
<meta property="og:url" content="https://appflowy.com" />
<meta name="twitter:card" content="summary_large_image" />
@ -22,15 +22,17 @@
content="AppFlowy is an AI collaborative workspace where you achieve more without losing control of your data"
/>
<meta name="twitter:image"
content="https://d3uafhn8yrvdfn.cloudfront.net/website/production/_next/static/media/og-image.e347bfb5.png"
content="/_next/static/media/og-image.e347bfb5.png"
/>
<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>
<script type="module" src="/src/main.tsx"></script>
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const userAgent = window.navigator.userAgent.toLowerCase();
@ -62,5 +64,8 @@
}
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.26.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.5.1/highlight.min.js"></script>
</body>
</html>

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",

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

View File

@ -1,38 +1,12 @@
<svg width='100%' height='100%' viewBox='0 0 41 40' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M39.9564 24.0195C38.8098 30.1683 33.7828 35.5321 28.0061 38.5411C27.3005 38.9336 26.4627 39.1516 25.6689 39.1952H37.9279C39.1185 39.1952 39.9564 38.323 39.9564 37.2328V24.0195Z'
fill='#F7931E'
/>
<path
d='M15.4381 12.1576C15.2617 12.2884 15.0853 12.4192 14.9089 12.55C11.9103 14.6432 2.82634 21.3589 0.753788 18.4371C-1.27467 15.6026 0.886079 7.57868 6.08952 3.69755C6.17771 3.61033 6.31 3.56672 6.3982 3.4795C12.0867 -0.48885 16.32 0.078058 18.3926 2.95621C20.3328 5.65992 18.1721 9.93353 15.4381 12.1576Z'
fill='#8427E0'
/>
<path
d='M33.8715 36.098C33.7833 36.1852 33.6951 36.2288 33.5628 36.316C27.8743 40.2844 23.641 39.7175 21.5684 36.8393C19.6282 34.1356 21.7889 29.862 24.5229 27.638C24.6993 27.5072 24.8757 27.3763 25.0521 27.2455C28.0507 25.1959 37.1347 18.4366 39.1631 21.3584C41.2357 24.1929 39.119 32.2169 33.8715 36.098Z'
fill='#FFBD00'
/>
<path
d='M17.9954 38.8459C15.085 40.8955 6.70658 38.6715 2.87014 33.264C2.78195 33.1768 2.69376 33.046 2.64966 32.9588C-1.09858 27.5078 -0.481224 23.4086 2.38508 21.4462C5.20728 19.4838 9.61698 21.7515 11.8218 24.586C11.91 24.7168 11.9982 24.804 12.0864 24.9349C14.159 27.8566 20.9499 36.8399 17.9954 38.8459Z'
fill='#E3006D'
/>
<path
d='M15.4385 12.1576C11.3816 13.9455 2.73857 17.6086 1.45976 14.6432C0.357338 12.1576 2.3858 7.09899 6.08994 3.69755C6.17814 3.61033 6.31043 3.56672 6.39862 3.4795C12.0871 -0.48885 16.3204 0.078058 18.393 2.95621C20.3333 5.65992 18.1725 9.93353 15.4385 12.1576Z'
fill='#9327FF'
/>
<path
d='M37.6624 18.3955C34.8402 20.3579 30.4305 18.0903 28.2257 15.2557C28.1375 15.1249 28.0493 15.0377 27.9611 14.9069C25.8444 11.9415 19.0535 2.95819 21.9639 0.952211C24.8743 -1.09738 33.2968 1.12664 37.1333 6.53407C37.2215 6.6649 37.3096 6.75211 37.3978 6.88294C41.102 12.334 40.5287 16.3895 37.6624 18.3955Z'
fill='#00B5FF'
/>
<path
d='M37.6628 18.3934C34.8406 20.3557 30.4309 18.0881 28.2261 15.2536C26.4181 11.1108 22.9344 2.95603 25.8448 1.73499C28.4906 0.601179 33.9587 2.86881 37.4423 6.88077C41.1024 12.3318 40.5291 16.3874 37.6628 18.3934Z'
fill='#00C8FF'
/>
<path
d='M33.8715 36.0986C33.7833 36.1858 33.6951 36.2294 33.5628 36.3166C27.8743 40.285 23.641 39.7181 21.5684 36.8399C19.6282 34.1362 21.7889 29.8626 24.5229 27.6386C28.5799 25.8506 37.2229 22.1875 38.5017 25.1529C39.6482 27.6386 37.6197 32.6971 33.8715 36.0986Z'
fill='#FFCE00'
/>
<path
d='M14.2031 38.061C11.5572 39.1948 6.08922 36.9708 2.64966 32.9588C-1.09858 27.5078 -0.481224 23.4086 2.38508 21.4462C5.20728 19.4838 9.61698 21.7515 11.8218 24.586C13.6298 28.6852 17.1135 36.8399 14.2031 38.061Z'
fill='#FB006D'
/>
</svg>
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M455.885 297.861C455.85 297.861 455.82 297.886 455.814 297.92C444.323 359.916 394.213 414.254 336.155 444.713C328.858 448.526 326.952 450.629 318.768 451.286H435.513C438.163 451.384 440.805 450.945 443.282 449.998C445.759 449.051 448.019 447.613 449.927 445.772C451.836 443.931 453.353 441.724 454.389 439.283C455.425 436.843 455.958 434.218 455.956 431.566V297.933C455.956 297.893 455.924 297.861 455.885 297.861V297.861Z" fill="#F7931E"/>
<path d="M327.511 447.668C325.125 447.832 322.731 447.832 320.345 447.668H327.511Z" fill="#FFCE00"/>
<path d="M210.239 178.025C208.465 179.504 206.69 180.852 204.882 182.134C175.071 203.103 84.0943 271.073 63.5522 241.821C43.3716 213.095 64.6368 131.617 117.027 92.3728C118.013 91.584 119 90.8609 120.018 90.1707C176.846 50.204 219.377 55.7257 240.05 85.0105C259.245 112.488 237.651 155.61 210.239 178.025Z" fill="#8427E0"/>
<path d="M432.62 241.066C404.486 260.787 360.082 237.78 338.061 209.481C337.173 208.33 336.319 207.18 335.497 205.997C314.528 176.088 246.624 85.1106 275.81 64.5356C304.996 43.9606 389.137 66.409 427.46 121.002C428.315 122.218 429.136 123.401 429.925 124.584C467.197 179.67 461.248 220.82 432.62 241.066Z" fill="#00B5FF"/>
<path d="M394.954 420.458C393.968 421.225 392.982 421.948 391.996 422.627C335.136 462.594 292.605 457.04 272.03 427.788C252.737 400.311 274.298 357.287 301.611 334.773C303.386 333.294 305.193 331.913 307.001 330.632C336.812 309.695 427.789 241.89 448.331 270.977C468.643 299.67 447.378 381.215 394.954 420.458Z" fill="#FFBD00"/>
<path d="M236.205 448.131C206.92 468.673 122.878 446.192 84.5216 391.632L82.2537 388.345C44.7849 333.095 50.701 291.978 79.3943 271.83C107.496 252.11 151.9 275.117 173.921 303.383C174.808 304.534 175.663 305.684 176.517 306.867C197.487 336.711 265.457 427.688 236.205 448.131Z" fill="#E3006D"/>
<path d="M240.905 137.634C230.873 159.981 212.651 177.627 189.993 186.935C146.049 205.439 81.5306 228.939 70.4214 203.369C59.5423 178.225 79.6572 127.018 116.995 92.3754L117.783 91.718C175.761 50.0421 219.114 55.3667 240.05 85.0131C250.403 99.902 248.859 119.458 240.905 137.634Z" fill="#9327FF"/>
<path d="M432.62 241.066C417.37 251.78 397.354 249.907 378.718 241.46C356.357 231.053 338.75 212.589 329.417 189.759C311.241 145.75 289.056 83.3019 314.101 72.4885C340.394 61.1164 395.382 83.7621 429.925 124.583C467.197 179.669 461.248 220.819 432.62 241.066Z" fill="#00C8FF"/>
<path d="M394.954 420.458L394.297 420.984C336.286 462.726 292.868 457.434 272.03 427.787C261.578 412.899 263.123 393.441 271.077 375.2C281.097 352.845 299.324 335.195 321.989 325.899C365.899 307.361 430.451 283.894 441.56 309.465C452.669 335.036 432.325 385.684 394.954 420.458Z" fill="#FFCE00"/>
<path d="M197.914 440.311C171.62 451.65 116.83 429.136 82.2537 388.446C44.7849 333.098 50.701 291.981 79.3943 271.833C94.6119 261.118 114.628 262.959 133.264 271.406C155.591 281.872 173.141 300.384 182.401 323.237C200.609 366.918 222.959 429.432 197.914 440.311Z" fill="#FB006D"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

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

View File

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

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

View File

@ -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,
@ -111,6 +116,8 @@ export interface TableCellBlockData extends BlockData {
height: number;
rowPosition: number;
width: number;
rowBackgroundColor: string;
colBackgroundColor: string;
}
export interface DatabaseNodeData extends BlockData {
@ -636,3 +643,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[];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +0,0 @@
export * from './selector';
export * from './context';

View File

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

View File

@ -0,0 +1,163 @@
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, useEffect, useRef } 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 prevViewMeta = useRef(viewMeta);
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]
);
useEffect(() => {
if (!viewMeta && prevViewMeta.current) {
window.location.reload();
return;
}
prevViewMeta.current = viewMeta;
}, [viewMeta]);
return (
<PublishContext.Provider
value={{
loadView,
viewMeta,
getViewRowsMap,
loadViewMeta,
toView,
namespace,
publishName,
}}
>
{children}
</PublishContext.Provider>
);
};
export function usePublishContext() {
return useContext(PublishContext);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
collabMap.set(collabId, collab);
if (exist) {
itemCallback?.(collabId, collab);
}
}
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);
}
return collabs;
}
export async function deleteViewMeta(name: string) {
await db.view_metas.delete(name);
}
export async function deleteView(name: string) {
console.log('deleteView', name);
await deleteViewMeta(name);
await closeCollabDB(name);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +0,0 @@
export async function signInSuccess() {
// Do nothing
}

View File

@ -1,3 +0,0 @@
export * from './token';
export * from './user';
export * from './auth';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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