feat: support sign-in and sign-up on Web (#5712)

This commit is contained in:
Kilu.He 2024-07-11 12:55:22 +08:00 committed by GitHub
parent 80afcf44c0
commit fe0fa9b530
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 935 additions and 161 deletions

View File

@ -170,7 +170,7 @@ SPEC CHECKSUMS:
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
image_gallery_saver: cb43cc43141711190510e92c460eb1655cd343cb
image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
@ -191,4 +191,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
COCOAPODS: 1.11.3
COCOAPODS: 1.15.2

View File

@ -25,14 +25,14 @@ class AboutSettingGroup extends StatelessWidget {
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.io/privacy/app'),
onTap: () => afLaunchUrlString('https://appflowy.io/privacy'),
),
MobileSettingItem(
name: LocaleKeys.settings_mobile_termsAndConditions.tr(),
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.io/terms/app'),
onTap: () => afLaunchUrlString('https://appflowy.io/terms'),
),
if (kDebugMode)
MobileSettingItem(

View File

@ -54,16 +54,8 @@ class DesktopSignInScreen extends StatelessWidget {
const SignInAnonymousButtonV2(),
const VSpace(10),
SwitchSignInSignUpButton(
onTap: () {
final type = state.loginType == LoginType.signIn
? LoginType.signUp
: LoginType.signIn;
context
.read<SignInBloc>()
.add(SignInEvent.switchLoginType(type));
},
),
// sign in agreement
const SignInAgreement(),
// loading status
const VSpace(indicatorMinHeight),

View File

@ -39,16 +39,7 @@ class MobileSignInScreen extends StatelessWidget {
const VSpace(spacing),
const SignInAnonymousButtonV2(),
const VSpace(spacing),
SwitchSignInSignUpButton(
onTap: () {
final type = state.loginType == LoginType.signIn
? LoginType.signUp
: LoginType.signIn;
context.read<SignInBloc>().add(
SignInEvent.switchLoginType(type),
);
},
),
const SignInAgreement(),
const VSpace(spacing),
_buildSettingsButton(context),
if (!isAuthEnabled) const Spacer(flex: 2),

View File

@ -1,14 +1,14 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/user/application/sign_in_bloc.dart';
import 'package:appflowy/workspace/presentation/home/toast.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/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart';
import 'package:toastification/toastification.dart';
class SignInWithMagicLinkButtons extends StatefulWidget {
const SignInWithMagicLinkButtons({super.key});
@ -53,18 +53,19 @@ class _SignInWithMagicLinkButtonsState
void _sendMagicLink(BuildContext context, String email) {
if (!isEmail(email)) {
return showSnackBarMessage(
return showToastNotification(
context,
LocaleKeys.signIn_invalidEmail.tr(),
duration: const Duration(seconds: 8),
message: LocaleKeys.signIn_invalidEmail.tr(),
type: ToastificationType.error,
);
}
context.read<SignInBloc>().add(SignInEvent.signedWithMagicLink(email));
showSnackBarMessage(
context,
LocaleKeys.signIn_magicLinkSent.tr(),
duration: const Duration(seconds: 1000),
showConfirmDialog(
context: context,
title: LocaleKeys.signIn_magicLinkSent.tr(),
description: LocaleKeys.signIn_magicLinkSentDescription.tr(),
);
}
}

View File

@ -0,0 +1,44 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
class SignInAgreement extends StatelessWidget {
const SignInAgreement({
super.key,
});
@override
Widget build(BuildContext context) {
return RichText(
textAlign: TextAlign.center,
text: TextSpan(
children: [
TextSpan(
text: '${LocaleKeys.web_signInAgreement.tr()} ',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
TextSpan(
text: '${LocaleKeys.web_termOfUse.tr()} ',
style: const TextStyle(color: Colors.blue, fontSize: 12),
mouseCursor: SystemMouseCursors.click,
recognizer: TapGestureRecognizer()
..onTap = () => afLaunchUrlString('https://appflowy.io/terms'),
),
TextSpan(
text: '${LocaleKeys.web_and.tr()} ',
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
TextSpan(
text: LocaleKeys.web_privacyPolicy.tr(),
style: const TextStyle(color: Colors.blue, fontSize: 12),
mouseCursor: SystemMouseCursors.click,
recognizer: TapGestureRecognizer()
..onTap = () => afLaunchUrlString('https://appflowy.io/privacy'),
),
],
),
);
}
}

View File

@ -1,5 +1,7 @@
export 'magic_link_sign_in_buttons.dart';
export 'sign_in_anonymous_button.dart';
export 'sign_in_or_logout_button.dart';
export 'switch_sign_in_sign_up_button.dart';
// export 'switch_sign_in_sign_up_button.dart';
export 'third_party_sign_in_buttons.dart';
export 'sign_in_agreement.dart';

View File

@ -223,9 +223,55 @@ class SpaceCancelOrConfirmButton extends StatelessWidget {
}
}
class ConfirmDeletionPopup extends StatefulWidget {
const ConfirmDeletionPopup({
class SpaceOkButton extends StatelessWidget {
const SpaceOkButton({
super.key,
required this.onConfirm,
required this.confirmButtonName,
this.confirmButtonColor,
});
final VoidCallback onConfirm;
final String confirmButtonName;
final Color? confirmButtonColor;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
DecoratedBox(
decoration: ShapeDecoration(
color: confirmButtonColor ?? Theme.of(context).colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: FlowyButton(
useIntrinsicWidth: true,
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0),
radius: BorderRadius.circular(8),
text: FlowyText.regular(
confirmButtonName,
color: Colors.white,
),
onTap: onConfirm,
),
),
],
);
}
}
enum ConfirmPopupStyle {
onlyOk,
cancelAndOk,
}
class ConfirmPopup extends StatefulWidget {
const ConfirmPopup({
super.key,
this.style = ConfirmPopupStyle.cancelAndOk,
required this.title,
required this.description,
required this.onConfirm,
@ -234,12 +280,13 @@ class ConfirmDeletionPopup extends StatefulWidget {
final String title;
final String description;
final VoidCallback onConfirm;
final ConfirmPopupStyle style;
@override
State<ConfirmDeletionPopup> createState() => _ConfirmDeletionPopupState();
State<ConfirmPopup> createState() => _ConfirmPopupState();
}
class _ConfirmDeletionPopupState extends State<ConfirmDeletionPopup> {
class _ConfirmPopupState extends State<ConfirmPopup> {
final focusNode = FocusNode();
@override
@ -262,46 +309,70 @@ class _ConfirmDeletionPopupState extends State<ConfirmDeletionPopup> {
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,
),
_buildTitle(),
const VSpace(6.0),
_buildDescription(),
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,
),
_buildStyledButton(context),
],
),
),
);
}
Widget _buildTitle() {
return Row(
children: [
Expanded(
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(),
),
],
);
}
Widget _buildDescription() {
return FlowyText.regular(
widget.description,
fontSize: 12.0,
color: Theme.of(context).hintColor,
maxLines: 3,
lineHeight: 1.4,
);
}
Widget _buildStyledButton(BuildContext context) {
switch (widget.style) {
case ConfirmPopupStyle.onlyOk:
return SpaceOkButton(
onConfirm: () {
widget.onConfirm();
Navigator.of(context).pop();
},
confirmButtonName: LocaleKeys.button_ok.tr(),
confirmButtonColor: Theme.of(context).colorScheme.primary,
);
case ConfirmPopupStyle.cancelAndOk:
return SpaceCancelOrConfirmButton(
onCancel: () => Navigator.of(context).pop(),
onConfirm: () {
widget.onConfirm();
Navigator.of(context).pop();
},
confirmButtonName: LocaleKeys.space_delete.tr(),
confirmButtonColor: Theme.of(context).colorScheme.error,
);
}
}
}
class SpacePopup extends StatelessWidget {

View File

@ -289,10 +289,11 @@ void showToastNotification(
BuildContext context, {
required String message,
String? description,
ToastificationType type = ToastificationType.success,
}) {
toastification.show(
context: context,
type: ToastificationType.success,
type: type,
style: ToastificationStyle.flat,
title: FlowyText(message),
description: description != null
@ -329,7 +330,7 @@ Future<void> showConfirmDeletionDialog({
),
child: SizedBox(
width: 440,
child: ConfirmDeletionPopup(
child: ConfirmPopup(
title: title,
description: description,
onConfirm: onConfirm,
@ -339,3 +340,30 @@ Future<void> showConfirmDeletionDialog({
},
);
}
Future<void> showConfirmDialog({
required BuildContext context,
required String title,
required String description,
VoidCallback? onConfirm,
}) {
return showDialog(
context: context,
builder: (_) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: SizedBox(
width: 440,
child: ConfirmPopup(
title: title,
description: description,
onConfirm: () => onConfirm?.call(),
style: ConfirmPopupStyle.onlyOk,
),
),
);
},
);
}

View File

@ -24,7 +24,7 @@
"coverage": "pnpm run test:unit && pnpm run test:components"
},
"dependencies": {
"@appflowyinc/client-api-wasm": "0.1.1",
"@appflowyinc/client-api-wasm": "0.1.2",
"@atlaskit/primitives": "^5.5.3",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",

View File

@ -1,13 +1,9 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@appflowyinc/client-api-wasm':
specifier: 0.1.1
version: 0.1.1
specifier: 0.1.2
version: 0.1.2
'@atlaskit/primitives':
specifier: ^5.5.3
version: 5.7.0(@types/react@18.2.66)(react@18.2.0)
@ -451,8 +447,8 @@ packages:
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25
/@appflowyinc/client-api-wasm@0.1.1:
resolution: {integrity: sha512-7+/TCmzMi9KrxX3HFLJv9R6ON2AO5xQavV547ii7RZM8+5bZJakuf6+pnyCzOquQX07q3ZYwJCa3MIgDvficaA==}
/@appflowyinc/client-api-wasm@0.1.2:
resolution: {integrity: sha512-+v0hs7/7BVKtgev/Bcbr0u2HLDhUuw4ZvZTaMddI+06HK8vt5S52dMaZKUcMvh1eUjVX8hjC6Mfe0X/yHqvFgA==}
dev: false
/@atlaskit/analytics-next-stable-react-context@1.0.1(react@18.2.0):
@ -11666,3 +11662,7 @@ packages:
resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==}
engines: {node: '>=12.20'}
dev: true
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

View File

@ -513,6 +513,7 @@ export function observeDeepRow(
export function useRowDataSelector(rowId: string) {
const rowMap = useRowDocMap();
const rowSharedRoot = rowMap?.get(rowId)?.getMap(YjsEditorKey.data_section);
const row = rowSharedRoot?.get(YjsEditorKey.database_row);

View File

@ -6,6 +6,8 @@ export type ViewMeta = {
child_views: PublishViewInfo[];
ancestor_views: PublishViewInfo[];
visible_view_ids: string[];
} & PublishViewInfo;
export type ViewMetasTable = {

View File

@ -9,6 +9,7 @@ import {
} from '@/application/services/js-services/cache';
import { openCollabDB, db } from '@/application/db';
import { StrategyType } from '@/application/services/js-services/cache/types';
import * as Y from 'yjs';
jest.mock('@/application/ydoc/apply', () => ({
applyYDoc: jest.fn(),
@ -118,9 +119,8 @@ describe('Cache functions', () => {
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([]);
(openCollabDB as jest.Mock).mockResolvedValue(new Y.Doc());
await expect(getBatchCollabs(['1', '2', '3'])).rejects.toThrow('No cache found');
});
it('should return collabs when cache found', async () => {

View File

@ -110,6 +110,8 @@ export async function getPublishViewMeta<
export async function getPublishView<
T extends {
data: number[];
rows?: Record<string, number[]>;
visibleViewIds?: string[];
meta: {
view: PublishViewInfo;
child_views: PublishViewInfo[];
@ -176,12 +178,15 @@ export async function revalidatePublishViewMeta<
>(name: string, fetcher: Fetcher<T>) {
const { view, child_views, ancestor_views } = await fetcher();
const dbView = await db.view_metas.get(name);
await db.view_metas.put(
{
publish_name: name,
...view,
child_views: child_views,
ancestor_views: ancestor_views,
visible_view_ids: dbView?.visible_view_ids ?? [],
},
name
);
@ -193,10 +198,11 @@ export async function revalidatePublishView<
T extends {
data: number[];
rows?: Record<string, number[]>;
visibleViewIds?: string[];
meta: PublishViewMetaData;
}
>(name: string, fetcher: Fetcher<T>, collab: YDoc) {
const { data, meta, rows } = await fetcher();
const { data, meta, rows, visibleViewIds = [] } = await fetcher();
await db.view_metas.put(
{
@ -204,6 +210,7 @@ export async function revalidatePublishView<
...meta.view,
child_views: meta.child_views,
ancestor_views: meta.ancestor_views,
visible_view_ids: visibleViewIds,
},
name
);
@ -222,7 +229,22 @@ export async function revalidatePublishView<
}
export async function getBatchCollabs(names: string[]) {
const collabs = await Promise.all(names.map((name) => openCollabDB(name)));
const getRowDoc = async (name: string) => {
const doc = await openCollabDB(name);
const exist = hasCollabCache(doc);
if (!exist) {
return Promise.reject(new Error('No cache found'));
}
return doc;
};
const collabs = await Promise.all(
names.map((name) => {
return getRowDoc(name);
})
);
return collabs;
}

View File

@ -8,9 +8,18 @@ import {
} 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 {
initAPIService,
signInGoogle,
signInWithMagicLink,
signInGithub,
signInDiscord,
signInWithUrl,
} from '@/application/services/js-services/wasm/client_api';
import { AFService, AFServiceConfig } from '@/application/services/services.type';
import { emit, EventType } from '@/application/session';
import { afterAuth, AUTH_CALLBACK_URL, withSignIn } from '@/application/session/sign_in';
import { nanoid } from 'nanoid';
import { initAPIService } from '@/application/services/js-services/wasm/client_api';
import * as Y from 'yjs';
export class AFClientService implements AFService {
@ -38,6 +47,10 @@ export class AFClientService implements AFService {
});
}
getClientId() {
return this.clientId;
}
async getPublishViewMeta(namespace: string, publishName: string) {
const viewMeta = await getPublishViewMeta(
() => {
@ -109,12 +122,13 @@ export class AFClientService implements AFService {
}
const rowsFolder: Y.Map<YDoc> = rootRowsDoc.getMap();
const docs = await getBatchCollabs(rowIds);
const docs = await getBatchCollabs(rowIds.map((id) => `${name}_${id}`));
docs.forEach((doc, index) => {
rowsFolder.set(rowIds[index], doc);
});
console.log('getPublishDatabaseViewRows', docs);
return {
rows: rowsFolder,
destroy: () => {
@ -149,4 +163,37 @@ export class AFClientService implements AFService {
return data;
}
async loginAuth(url: string) {
try {
console.log('loginAuth', url);
await signInWithUrl(url);
emit(EventType.SESSION_VALID);
afterAuth();
return;
} catch (e) {
emit(EventType.SESSION_INVALID);
return Promise.reject(e);
}
}
@withSignIn()
async signInMagicLink({ email }: { email: string; redirectTo: string }) {
return await signInWithMagicLink(email, AUTH_CALLBACK_URL);
}
@withSignIn()
async signInGoogle(_: { redirectTo: string }) {
return await signInGoogle(AUTH_CALLBACK_URL);
}
@withSignIn()
async signInGithub(_: { redirectTo: string }) {
return await signInGithub(AUTH_CALLBACK_URL);
}
@withSignIn()
async signInDiscord(_: { redirectTo: string }) {
return await signInDiscord(AUTH_CALLBACK_URL);
}
}

View File

@ -1,6 +1,7 @@
import { getToken, invalidToken, isTokenValid, refreshToken } from '@/application/session/token';
import { ClientAPI } from '@appflowyinc/client-api-wasm';
import { AFCloudConfig } from '@/application/services/services.type';
import { PublishViewMetaData } from '@/application/collab.type';
import { PublishViewMetaData, ViewLayout } from '@/application/collab.type';
let client: ClientAPI;
@ -14,13 +15,9 @@ export function initAPIService(
return;
}
window.refresh_token = () => {
//
};
window.refresh_token = refreshToken;
window.invalid_token = () => {
// invalidToken();
};
window.invalid_token = invalidToken;
client = ClientAPI.new({
base_url: config.baseURL,
@ -34,16 +31,45 @@ export function initAPIService(
},
});
if (isTokenValid()) {
client.restore_token(getToken() || '');
}
client.subscribe();
}
export async function getPublishView(publishNamespace: string, publishName: string) {
const data = await client.get_publish_view(publishNamespace, publishName);
return {
data: data.data,
meta: JSON.parse(data.meta.data) as PublishViewMetaData,
};
const meta = JSON.parse(data.meta.data) as PublishViewMetaData;
if (meta.view.layout === ViewLayout.Document) {
return {
data: data.data,
meta,
};
}
try {
const decoder = new TextDecoder('utf-8');
const jsonStr = decoder.decode(new Uint8Array(data.data));
const res = JSON.parse(jsonStr) as {
database_collab: number[];
database_row_collabs: Record<string, number[]>;
database_row_document_collabs: Record<string, number[]>;
visible_database_view_ids: string[];
};
console.log('getPublishView', res);
return {
data: res.database_collab,
rows: res.database_row_collabs,
visibleViewIds: res.visible_database_view_ids,
meta,
};
} catch (e) {
return Promise.reject(e);
}
}
export async function getPublishInfoWithViewId(viewId: string) {
@ -56,3 +82,33 @@ export async function getPublishViewMeta(publishNamespace: string, publishName:
return metadata;
}
export async function signInWithUrl(url: string) {
return client.sign_in_with_url(url);
}
export async function signInWithMagicLink(email: string, redirectTo: string) {
return client.sign_in_with_magic_link(email, redirectTo);
}
export async function signInGoogle(redirectTo: string) {
return signInProvider('google', redirectTo);
}
export async function signInProvider(provider: string, redirectTo: string) {
try {
const { url } = await client.generate_oauth_url_with_provider(provider, redirectTo);
window.open(url, '_current');
} catch (e) {
return Promise.reject(e);
}
}
export async function signInGithub(redirectTo: string) {
return signInProvider('github', redirectTo);
}
export async function signInDiscord(redirectTo: string) {
return signInProvider('discord', redirectTo);
}

View File

@ -15,6 +15,7 @@ export interface AFCloudConfig {
}
export interface PublishService {
getClientId: () => string;
getPublishViewMeta: (namespace: string, publishName: string) => Promise<ViewMeta>;
getPublishView: (namespace: string, publishName: string) => Promise<YDoc>;
getPublishInfo: (viewId: string) => Promise<{ namespace: string; publishName: string }>;
@ -26,4 +27,10 @@ export interface PublishService {
rows: Y.Map<YDoc>;
destroy: () => void;
}>;
loginAuth: (url: string) => Promise<void>;
signInMagicLink: (params: { email: string; redirectTo: string }) => Promise<void>;
signInGoogle: (params: { redirectTo: string }) => Promise<void>;
signInGithub: (params: { redirectTo: string }) => Promise<void>;
signInDiscord: (params: { redirectTo: string }) => Promise<void>;
}

View File

@ -21,4 +21,28 @@ export class AFClientService implements AFService {
async getPublishDatabaseViewRows(_namespace: string, _publishName: string, _rowIds: string[]) {
return Promise.reject('Method not implemented');
}
getClientId(): string {
return '';
}
loginAuth(_: string): Promise<void> {
return Promise.resolve(undefined);
}
signInDiscord(_params: { redirectTo: string }): Promise<void> {
return Promise.resolve(undefined);
}
signInGithub(_params: { redirectTo: string }): Promise<void> {
return Promise.resolve(undefined);
}
signInGoogle(_params: { redirectTo: string }): Promise<void> {
return Promise.resolve(undefined);
}
signInMagicLink(_params: { email: string; redirectTo: string }): Promise<void> {
return Promise.resolve(undefined);
}
}

View File

@ -0,0 +1,24 @@
import { EventEmitter } from 'events';
const event = new EventEmitter();
export enum EventType {
SESSION_EXPIRED = 'session_expired',
SESSION_REFRESH = 'session_refresh',
SESSION_INVALID = 'session_invalid',
SESSION_VALID = 'session_valid',
}
export type Listener<T> = (data: T) => void;
export function on<T>(eventType: EventType, listener: Listener<T>) {
event.on(eventType, listener);
return () => {
event.off(eventType, listener);
};
}
export function emit<T>(eventType: EventType, data?: T) {
event.emit(eventType, data);
}

View File

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

View File

@ -0,0 +1,51 @@
export function saveRedirectTo(redirectTo: string) {
localStorage.setItem('redirectTo', redirectTo);
}
export function getRedirectTo() {
return localStorage.getItem('redirectTo');
}
export function clearRedirectTo() {
localStorage.removeItem('redirectTo');
}
export const AUTH_CALLBACK_PATH = '/auth/callback';
export const AUTH_CALLBACK_URL = `${window.location.origin}${AUTH_CALLBACK_PATH}`;
export function withSignIn() {
return function (
// eslint-disable-next-line
_target: any,
_propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
// eslint-disable-next-line
descriptor.value = async function (args: { redirectTo: string }) {
const redirectTo = args.redirectTo;
saveRedirectTo(redirectTo);
console.log('=====saveRedirectTo', redirectTo);
try {
await originalMethod.apply(this, [args]);
} catch (e) {
console.error(e);
return Promise.reject(e);
}
};
return descriptor;
};
}
export function afterAuth() {
const redirectTo = getRedirectTo();
if (redirectTo) {
clearRedirectTo();
window.location.href = redirectTo;
}
}

View File

@ -0,0 +1,20 @@
import { emit, EventType } from '@/application/session/event';
export function refreshToken(token: string) {
localStorage.removeItem('token');
localStorage.setItem('token', token);
emit(EventType.SESSION_REFRESH, token);
}
export function invalidToken() {
localStorage.removeItem('token');
emit(EventType.SESSION_INVALID);
}
export function isTokenValid() {
return !!localStorage.getItem('token');
}
export function getToken() {
return localStorage.getItem('token');
}

View File

@ -0,0 +1,12 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="login">
<g id="login_2">
<path id="Vector" d="M11.6406 14.9475L14.5206 12.0675L11.6406 9.1875" stroke="currentColor"
stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M3 12.0674H14.4413" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"
stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M12 3C16.9725 3 21 6.375 21 12C21 17.625 16.9725 21 12 21" stroke="currentColor"
stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 793 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="16" viewBox="0 0 20 16" fill="none">
<path d="M16.3916 2.18032C16.3865 2.17042 16.378 2.16267 16.3677 2.15844C15.1764 1.61184 13.9191 1.22207 12.6275 0.998881C12.6158 0.996699 12.6037 0.998273 12.5929 1.00338C12.5821 1.00848 12.5732 1.01686 12.5674 1.02732C12.3962 1.33803 12.2408 1.65719 12.1018 1.98357C10.7095 1.77222 9.29329 1.77222 7.901 1.98357C7.76105 1.65636 7.60315 1.33713 7.42803 1.02732C7.42202 1.01709 7.41307 1.0089 7.40235 1.00383C7.39162 0.99876 7.37962 0.997034 7.3679 0.998881C6.07615 1.2216 4.81885 1.6114 3.62765 2.15847C3.61745 2.1628 3.60885 2.17018 3.60303 2.1796C1.22087 5.73704 0.568308 9.20701 0.888433 12.634C0.889334 12.6424 0.891914 12.6505 0.896021 12.6579C0.900128 12.6653 0.905677 12.6718 0.912339 12.677C2.29945 13.704 3.85094 14.488 5.50062 14.9954C5.51224 14.9989 5.52464 14.9987 5.53617 14.9949C5.5477 14.9912 5.55779 14.9839 5.56509 14.9743C5.9194 14.4922 6.23335 13.9817 6.50375 13.4479C6.50746 13.4406 6.50958 13.4326 6.50997 13.4244C6.51036 13.4162 6.509 13.408 6.50599 13.4004C6.50298 13.3927 6.49839 13.3858 6.49251 13.3801C6.48664 13.3743 6.47961 13.3699 6.4719 13.3671C5.97683 13.1776 5.49754 12.9493 5.03853 12.6842C5.03019 12.6793 5.02319 12.6724 5.01814 12.6641C5.01309 12.6559 5.01015 12.6465 5.00958 12.6369C5.00901 12.6272 5.01082 12.6176 5.01486 12.6088C5.0189 12.6 5.02504 12.5923 5.03275 12.5865C5.12933 12.5143 5.22424 12.44 5.3174 12.3634C5.32557 12.3567 5.33546 12.3524 5.34595 12.351C5.35644 12.3496 5.36712 12.3511 5.37678 12.3554C8.38393 13.7278 11.6396 13.7278 14.6112 12.3554C14.6208 12.3508 14.6316 12.3491 14.6423 12.3504C14.6529 12.3517 14.663 12.3559 14.6713 12.3627C14.7645 12.4396 14.8597 12.5143 14.9567 12.5865C14.9644 12.5923 14.9706 12.5999 14.9747 12.6086C14.9788 12.6174 14.9807 12.627 14.9802 12.6367C14.9797 12.6463 14.9768 12.6557 14.9718 12.664C14.9668 12.6723 14.9599 12.6792 14.9516 12.6842C14.4936 12.9515 14.0139 13.1797 13.5175 13.3663C13.5098 13.3693 13.5028 13.3738 13.497 13.3796C13.4911 13.3855 13.4866 13.3925 13.4836 13.4002C13.4807 13.4079 13.4794 13.4161 13.4799 13.4243C13.4803 13.4326 13.4825 13.4406 13.4863 13.4479C13.7612 13.9787 14.0747 14.4885 14.4242 14.9734C14.4313 14.9834 14.4414 14.9908 14.4529 14.9947C14.4645 14.9987 14.477 14.9989 14.4887 14.9953C16.1413 14.4896 17.6955 13.7056 19.0844 12.677C19.0911 12.672 19.0968 12.6657 19.1009 12.6584C19.105 12.6511 19.1075 12.6431 19.1083 12.6347C19.4915 8.67276 18.4667 5.23122 16.3916 2.18032ZM6.95284 10.5473C6.04746 10.5473 5.30146 9.71647 5.30146 8.6961C5.30146 7.67572 6.033 6.84482 6.95284 6.84482C7.87987 6.84482 8.61865 7.68294 8.60418 8.69604C8.60418 9.71647 7.87262 10.5473 6.95284 10.5473ZM13.0585 10.5473C12.1531 10.5473 11.4071 9.71647 11.4071 8.6961C11.4071 7.67572 12.1387 6.84482 13.0585 6.84482C13.9856 6.84482 14.7243 7.68294 14.7098 8.69604C14.7098 9.71647 13.9856 10.5473 13.0585 10.5473Z"
fill="#5865F2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10.0002 0.83128C7.59472 0.806836 5.27798 1.73845 3.55911 3.42138C1.84023 5.10431 0.85988 7.40085 0.833496 9.80628C0.842765 11.7073 1.45428 13.5564 2.58021 15.0881C3.70615 16.6198 5.28856 17.7552 7.10016 18.3313C7.5585 18.4146 7.72516 18.1396 7.72516 17.8979V16.3729C5.17516 16.9146 4.6335 15.1729 4.6335 15.1729C4.46374 14.6262 4.10286 14.1588 3.61683 13.8563C2.7835 13.3063 3.6835 13.3146 3.6835 13.3146C3.97154 13.3531 4.24712 13.4563 4.48959 13.6165C4.73205 13.7767 4.93509 13.9898 5.0835 14.2396C5.34211 14.688 5.76644 15.017 6.26517 15.1557C6.7639 15.2944 7.29716 15.2318 7.75016 14.9813C7.79675 14.5249 8.00341 14.0998 8.3335 13.7813C6.30016 13.5563 4.16683 12.7896 4.16683 9.34795C4.14865 8.45018 4.48036 7.58054 5.09183 6.92295C4.81335 6.15217 4.84623 5.30321 5.1835 4.55628C5.1835 4.55628 5.9585 4.31461 7.6835 5.47295C9.18507 5.07281 10.7653 5.07281 12.2668 5.47295C14.0168 4.31461 14.7668 4.55628 14.7668 4.55628C15.1041 5.30321 15.137 6.15217 14.8585 6.92295C15.4842 7.56849 15.8339 8.43229 15.8335 9.33128C15.8335 12.7813 13.6835 13.5396 11.6668 13.7646C11.8876 13.979 12.0582 14.2397 12.1663 14.5278C12.2744 14.816 12.3172 15.1246 12.2918 15.4313V17.8896C12.2918 18.1813 12.4585 18.4146 12.9168 18.3229C14.7233 17.7433 16.3004 16.6076 17.4228 15.0781C18.5453 13.5485 19.1557 11.7034 19.1668 9.80628C19.1404 7.40085 18.1601 5.10431 16.4412 3.42138C14.7223 1.73845 12.4056 0.806836 10.0002 0.83128Z"
fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="20" viewBox="0 0 21 20" fill="none">
<g clip-path="url(#clip0_346_13747)">
<path d="M4.68181 9.99793C4.68181 9.36293 4.79014 8.75376 4.98181 8.18293L1.61681 5.66626C0.940723 7.00999 0.589632 8.4937 0.591807 9.99793C0.591807 11.5546 0.96014 13.0229 1.61514 14.3263L4.97847 11.8054C4.78236 11.223 4.68217 10.6125 4.68181 9.99793Z"
fill="#FBBC05"/>
<path d="M10.5917 4.22046C12 4.22046 13.2725 4.70879 14.2725 5.50879L17.1817 2.66463C15.4092 1.15379 13.1367 0.220459 10.5917 0.220459C6.64003 0.220459 3.24337 2.43296 1.6167 5.66629L4.98337 8.18296C5.75837 5.87796 7.96837 4.22046 10.5917 4.22046Z"
fill="#EA4335"/>
<path d="M10.5917 15.7755C7.96753 15.7755 5.75753 14.118 4.9817 11.813L1.6167 14.3297C3.24253 17.563 6.6392 19.7755 10.5917 19.7755C13.03 19.7755 15.3584 18.928 17.1067 17.3388L13.9117 14.9205C13.0109 15.4763 11.8759 15.7755 10.5909 15.7755"
fill="#34A853"/>
<path d="M20.1367 9.99796C20.1367 9.42046 20.045 8.79796 19.9092 8.22046H10.5908V11.998H15.9542C15.6867 13.2863 14.9567 14.2763 13.9125 14.9205L17.1067 17.3388C18.9425 15.6705 20.1367 13.1855 20.1367 9.99796Z"
fill="#4285F4"/>
</g>
<defs>
<clipPath id="clip0_346_13747">
<rect width="20" height="20" fill="white" transform="translate(0.5 -0.0020752)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,4 +1,7 @@
import { AUTH_CALLBACK_PATH } from '@/application/session/sign_in';
import NotFound from '@/components/error/NotFound';
import LoginAuth from '@/components/login/LoginAuth';
import LoginPage from '@/pages/LoginPage';
import PublishPage from '@/pages/PublishPage';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import withAppWrapper from '@/components/app/withAppWrapper';
@ -8,6 +11,8 @@ const AppMain = withAppWrapper(() => {
return (
<Routes>
<Route path={'/:namespace/:publishName'} element={<PublishPage />} />
<Route path={'/login'} element={<LoginPage />} />
<Route path={AUTH_CALLBACK_PATH} element={<LoginAuth />} />
<Route path='/404' element={<NotFound />} />
<Route path='*' element={<NotFound />} />
</Routes>

View File

@ -1,3 +1,5 @@
import { EventType, on } from '@/application/session';
import { isTokenValid } from '@/application/session/token';
import { useAppLanguage } from '@/components/app/useAppLanguage';
import { useSnackbar } from 'notistack';
import React, { createContext, useEffect, useState } from 'react';
@ -19,6 +21,7 @@ const defaultConfig: AFServiceConfig = {
export const AFConfigContext = createContext<
| {
service: AFService | undefined;
isAuthenticated: boolean;
}
| undefined
>(undefined);
@ -26,7 +29,29 @@ export const AFConfigContext = createContext<
function AppConfig({ children }: { children: React.ReactNode }) {
const [appConfig] = useState<AFServiceConfig>(defaultConfig);
const [service, setService] = useState<AFService>();
const [isAuthenticated, setIsAuthenticated] = React.useState<boolean>(isTokenValid());
useEffect(() => {
return on(EventType.SESSION_VALID, () => {
setIsAuthenticated(true);
});
}, []);
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'token') setIsAuthenticated(isTokenValid());
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
useEffect(() => {
return on(EventType.SESSION_INVALID, () => {
setIsAuthenticated(false);
});
}, []);
useAppLanguage();
useEffect(() => {
@ -67,6 +92,7 @@ function AppConfig({ children }: { children: React.ReactNode }) {
<AFConfigContext.Provider
value={{
service,
isAuthenticated,
}}
>
{children}

View File

@ -1,9 +1,10 @@
import { YDatabase, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/collab.type';
import { ViewMeta } from '@/application/db/tables/view_metas';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
import DatabaseHeader from '@/components/database/components/header/DatabaseHeader';
import DatabaseRow from '@/components/database/DatabaseRow';
import DatabaseViews from '@/components/database/DatabaseViews';
import { ViewMetaPreview, ViewMetaProps } from '@/components/view-meta/ViewMetaPreview';
import { ViewMetaProps } from '@/components/view-meta/ViewMetaPreview';
import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import * as Y from 'yjs';
@ -27,7 +28,7 @@ function Database({ doc, getViewRowsMap, navigateToView, loadViewMeta, loadView,
const database = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as YDatabase;
const rows = database.get(YjsDatabaseKey.views).get(viewId).get(YjsDatabaseKey.row_orders);
return rows.toArray().map((row) => row.get(YjsDatabaseKey.id));
return rows.toJSON().map((row) => row.id);
}, [doc, viewId]);
const iidIndex = useMemo(() => {
@ -70,7 +71,12 @@ function Database({ doc, getViewRowsMap, navigateToView, loadViewMeta, loadView,
}
return (
<div className={'flex w-full justify-center'}>
<div
style={{
height: 'calc(100vh - 48px)',
}}
className={'flex w-full justify-center'}
>
<Suspense fallback={<ComponentLoading />}>
<DatabaseContextProvider
isDatabaseRowPage={!!rowId}
@ -87,10 +93,15 @@ function Database({ doc, getViewRowsMap, navigateToView, loadViewMeta, loadView,
<DatabaseRow rowId={rowId} />
) : (
<div className={'relative flex h-full w-full flex-col'}>
{viewMeta && <ViewMetaPreview {...viewMeta} />}
{viewMeta && <DatabaseHeader {...viewMeta} />}
<div className='appflowy-database relative flex w-full flex-1 select-text flex-col overflow-y-hidden'>
<DatabaseViews iidIndex={iidIndex} onChangeView={handleChangeView} viewId={viewId} />
<DatabaseViews
iidIndex={iidIndex}
viewName={viewMeta.name}
onChangeView={handleChangeView}
viewId={viewId}
/>
</div>
</div>
)}

View File

@ -15,10 +15,12 @@ function DatabaseViews({
onChangeView,
viewId,
iidIndex,
viewName,
}: {
onChangeView: (viewId: string) => void;
viewId: string;
iidIndex: string;
viewName?: string;
}) {
const { childViews, viewIds } = useDatabaseViewsSelector(iidIndex);
@ -60,7 +62,13 @@ function DatabaseViews({
toggleExpanded,
}}
>
<DatabaseTabs selectedViewId={viewId} setSelectedViewId={onChangeView} viewIds={viewIds} />
<DatabaseTabs
viewName={viewName}
iidIndex={iidIndex}
selectedViewId={viewId}
setSelectedViewId={onChangeView}
viewIds={viewIds}
/>
<DatabaseConditions />
</DatabaseConditionsContext.Provider>
<div className={'flex h-full w-full flex-1 flex-col overflow-hidden'}>

View File

@ -1,4 +1,4 @@
import { useFieldsSelector, useNavigateToRow } from '@/application/database-yjs';
import { useFieldsSelector } from '@/application/database-yjs';
import CardField from '@/components/database/components/field/CardField';
import React, { memo, useEffect, useMemo } from 'react';
@ -32,12 +32,12 @@ export const Card = memo(({ groupFieldId, rowId, onResize, isDragging }: CardPro
};
}, [onResize, isDragging]);
const navigateToRow = useNavigateToRow();
// const navigateToRow = useNavigateToRow();
return (
<div
onClick={() => {
navigateToRow?.(rowId);
// navigateToRow?.(rowId);
}}
ref={ref}
style={{

View File

@ -1,4 +1,4 @@
import { CalendarEvent, useFieldsSelector, useNavigateToRow } from '@/application/database-yjs';
import { CalendarEvent, useFieldsSelector } from '@/application/database-yjs';
import { RichTooltip } from '@/components/_shared/popover';
import EventPaper from '@/components/database/components/calendar/event/EventPaper';
import CardField from '@/components/database/components/field/CardField';
@ -11,7 +11,7 @@ export function Event({ event }: EventWrapperProps<CalendarEvent>) {
const fields = useFieldsSelector();
const showFields = useMemo(() => fields.filter((field) => field.fieldId !== fieldId), [fields, fieldId]);
const navigateToRow = useNavigateToRow();
// const navigateToRow = useNavigateToRow();
const [open, setOpen] = React.useState(false);
return (
@ -20,7 +20,7 @@ export function Event({ event }: EventWrapperProps<CalendarEvent>) {
<div
onClick={() => {
if (window.innerWidth < 768) {
navigateToRow?.(rowId);
// navigateToRow?.(rowId);
} else {
setOpen((prev) => !prev);
}

View File

@ -1,6 +1,6 @@
import { useFieldsSelector, usePrimaryFieldId } from '@/application/database-yjs';
import EventPaperTitle from '@/components/database/components/calendar/event/EventPaperTitle';
import OpenAction from '@/components/database/components/database-row/OpenAction';
// import OpenAction from '@/components/database/components/database-row/OpenAction';
import { Property } from '@/components/database/components/property';
import React from 'react';
@ -12,9 +12,9 @@ function EventPaper({ rowId }: { rowId: string }) {
return (
<div className={'max-h-[260px] w-[360px] overflow-y-auto'}>
<div className={'flex h-fit w-full flex-col items-center justify-center py-2 px-3'}>
<div className={'flex w-full items-center justify-end'}>
<OpenAction rowId={rowId} />
</div>
{/*<div className={'flex w-full items-center justify-end'}>*/}
{/* <OpenAction rowId={rowId} />*/}
{/*</div>*/}
<div className={'event-properties flex w-full flex-1 flex-col gap-4 overflow-y-auto py-2'}>
{primaryFieldId && <EventPaperTitle rowId={rowId} fieldId={primaryFieldId} />}
{fields.map((field) => {

View File

@ -1,7 +1,6 @@
import { useNavigateToRow, useRowMetaSelector } from '@/application/database-yjs';
import { TextCell as CellType, CellProps } from '@/application/database-yjs/cell.type';
import { TextCell } from '@/components/database/components/cell/text';
import OpenAction from '@/components/database/components/database-row/OpenAction';
import { getPlatform } from '@/utils/platform';
import React, { useEffect, useMemo, useState } from 'react';
@ -10,7 +9,7 @@ export function PrimaryCell(props: CellProps<CellType>) {
const meta = useRowMetaSelector(rowId);
const icon = meta?.icon;
const [hover, setHover] = useState(false);
const [, setHover] = useState(false);
useEffect(() => {
const table = document.querySelector('.grid-table');
@ -61,11 +60,11 @@ export function PrimaryCell(props: CellProps<CellType>) {
<TextCell {...props} />
</div>
{hover && (
<div className={'absolute right-0 top-1/2 min-w-0 -translate-y-1/2 transform '}>
<OpenAction rowId={rowId} />
</div>
)}
{/*{hover && (*/}
{/* <div className={'absolute right-0 top-1/2 min-w-0 -translate-y-1/2 transform '}>*/}
{/* <OpenAction rowId={rowId} />*/}
{/* </div>*/}
{/*)}*/}
</div>
);
}

View File

@ -18,6 +18,7 @@ export interface GridTableProps {
export const GridTable = ({ scrollLeft, columnWidth, columns, onScrollLeft }: GridTableProps) => {
const ref = useRef<VariableSizeGrid | null>(null);
const { rows } = useRenderRows();
const forceUpdate = useCallback((index: number) => {
ref.current?.resetAfterRowIndex(index, true);
}, []);

View File

@ -0,0 +1,38 @@
import { ViewLayout, ViewMetaIcon } from '@/application/collab.type';
import { ViewIcon } from '@/components/_shared/view-icon';
import React from 'react';
import { useTranslation } from 'react-i18next';
function DatabaseHeader({
icon,
name,
layout,
}: {
icon?: ViewMetaIcon;
name?: string;
viewId?: string;
layout?: ViewLayout;
}) {
const { t } = useTranslation();
return (
<div
className={
'my-10 flex w-full items-center gap-4 overflow-hidden whitespace-pre-wrap break-words break-all px-16 text-[2.25rem] font-bold leading-[1.5em] max-md:px-4 max-sm:text-[7vw]'
}
>
<div className={'relative'}>
{icon?.value ? (
<div className={'view-icon'}>{icon?.value}</div>
) : (
<ViewIcon layout={layout || ViewLayout.Grid} size={10} />
)}
</div>
<div className={'relative'}>
{name || <span className={'text-text-placeholder'}>{t('menuAppHeader.defaultNewPageName')}</span>}
</div>
</div>
);
}
export default DatabaseHeader;

View File

@ -14,6 +14,8 @@ export interface DatabaseTabBarProps {
viewIds: string[];
selectedViewId?: string;
setSelectedViewId?: (viewId: string) => void;
viewName?: string;
iidIndex: string;
}
const DatabaseIcons: {
@ -25,7 +27,7 @@ const DatabaseIcons: {
};
export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
({ viewIds, selectedViewId, setSelectedViewId }, ref) => {
({ viewIds, viewName, iidIndex, selectedViewId, setSelectedViewId }, ref) => {
const { t } = useTranslation();
const view = useDatabaseView();
const views = useDatabase().get(YjsDatabaseKey.views);
@ -69,7 +71,7 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
if (!view) return null;
const layout = Number(view.get(YjsDatabaseKey.layout)) as DatabaseViewLayout;
const Icon = DatabaseIcons[layout];
const name = view.get(YjsDatabaseKey.name);
const name = viewId === iidIndex ? viewName : view.get(YjsDatabaseKey.name);
return (
<ViewTab

View File

@ -11,25 +11,22 @@ const NotFound = () => {
return (
<div className={'m-0 flex h-screen w-screen items-center justify-center bg-bg-body p-0'}>
<div className={'flex flex-col items-center gap-1 text-center'}>
<Typography
variant='h3'
className={'mb-[27px] flex items-center gap-4 text-text-title'}
component='h2'
gutterBottom
>
<Logo className={'w-9'} />
<AppflowyLogo className={'w-32'} />
<Typography variant='h3' className={'mb-[27px] flex items-center gap-4 text-text-title'} gutterBottom>
<>
<Logo className={'w-9'} />
<AppflowyLogo className={'w-32'} />
</>
</Typography>
<Typography className={' text-[52px] font-semibold leading-[128%] text-text-title'} gutterBottom>
<div className={'mb-[16px] text-[52px] font-semibold leading-[128%] text-text-title'}>
{t('publish.noAccessToVisit')}
</Typography>
<Typography className={'text-[20px] leading-[152%]'} gutterBottom>
<div className={''}>{t('publish.createWithAppFlowy')}</div>
</div>
<div className={'text-[20px] leading-[152%]'}>
<div>{t('publish.createWithAppFlowy')}</div>
<div className={'flex items-center gap-1'}>
<span className={'font-semibold text-fill-default'}>{t('publish.fastWithAI')}</span>
<span>{t('publish.tryItNow')}</span>
<div className={'font-semibold text-fill-default'}>{t('publish.fastWithAI')}</div>
<div>{t('publish.tryItNow')}</div>
</div>
</Typography>
</div>
<Button
component={Link}
to='https://appflowy.io/download'

View File

@ -0,0 +1,53 @@
import { AFConfigContext } from '@/components/app/AppConfig';
import LoginProvider from '@/components/login/LoginProvider';
import MagicLink from '@/components/login/MagicLink';
import { Divider } from '@mui/material';
import React, { useContext, useEffect } from 'react';
import { ReactComponent as Logo } from '@/assets/logo.svg';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
export function Login() {
const { t } = useTranslation();
const [search] = useSearchParams();
const redirectTo = search.get('redirectTo') || window.location.href;
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
useEffect(() => {
if (isAuthenticated && encodeURIComponent(redirectTo) !== window.location.href) {
window.location.href = redirectTo;
}
}, [isAuthenticated, redirectTo]);
return (
<div className={'my-10 flex flex-col items-center justify-center gap-[24px] px-4'}>
<div className={'flex flex-col items-center justify-center gap-[14px]'}>
<Logo className={'h-10 w-10'} />
<div className={'text-[24px] font-semibold'}>{t('welcomeTo')} AppFlowy</div>
</div>
<MagicLink redirectTo={redirectTo} />
<div className={'flex w-full items-center justify-center gap-2 text-text-caption'}>
<Divider className={'flex-1 border-line-divider'} />
{t('web.or')}
<Divider className={'flex-1 border-line-divider'} />
</div>
<LoginProvider redirectTo={redirectTo} />
<div
className={
'mt-[40px] w-[300px] overflow-hidden whitespace-pre-wrap break-words text-center text-[12px] tracking-[0.36px] text-text-caption'
}
>
<span>{t('web.signInAgreement')} </span>
<a href={'https://appflowy.io/terms'} target={'_blank'} className={'text-fill-default underline'}>
{t('web.termOfUse')}
</a>{' '}
{t('web.and')}{' '}
<a href={'https://appflowy.io/privacy'} target={'_blank'} className={'text-fill-default underline'}>
{t('web.privacyPolicy')}
</a>
.
</div>
</div>
);
}
export default Login;

View File

@ -0,0 +1,28 @@
import { AFConfigContext } from '@/components/app/AppConfig';
import { CircularProgress } from '@mui/material';
import { useContext, useEffect, useState } from 'react';
function LoginAuth() {
const service = useContext(AFConfigContext)?.service;
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
void (async () => {
setLoading(true);
try {
await service?.loginAuth(window.location.href);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
})();
}, [service]);
return loading ? (
<div className={'flex h-screen w-screen items-center justify-center'}>
<CircularProgress />
</div>
) : null;
}
export default LoginAuth;

View File

@ -0,0 +1,72 @@
import { notify } from '@/components/_shared/notify';
import { AFConfigContext } from '@/components/app/AppConfig';
import { Button } from '@mui/material';
import React, { useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as GoogleSvg } from '@/assets/login/google.svg';
import { ReactComponent as GithubSvg } from '@/assets/login/github.svg';
import { ReactComponent as DiscordSvg } from '@/assets/login/discord.svg';
function LoginProvider({ redirectTo }: { redirectTo: string }) {
const { t } = useTranslation();
const options = useMemo(
() => [
{
label: t('web.continueWithGoogle'),
Icon: GoogleSvg,
value: 'google',
},
{
label: t('web.continueWithGithub'),
value: 'github',
Icon: GithubSvg,
},
{
label: t('web.continueWithDiscord'),
value: 'discord',
Icon: DiscordSvg,
},
],
[t]
);
const service = useContext(AFConfigContext)?.service;
const handleClick = async (option: string) => {
try {
switch (option) {
case 'google':
await service?.signInGoogle({ redirectTo });
break;
case 'github':
await service?.signInGithub({ redirectTo });
break;
case 'discord':
await service?.signInDiscord({ redirectTo });
break;
}
} catch (e) {
notify.error(t('web.signInError'));
}
};
return (
<div className={'flex flex-col items-center justify-center gap-[10px]'}>
{options.map((option) => (
<Button
key={option.value}
color={'inherit'}
variant={'outlined'}
onClick={() => handleClick(option.value)}
className={
'flex h-[46px] w-[380px] items-center justify-center gap-[10px] rounded-[12px] border border-line-divider text-sm font-medium'
}
>
<option.Icon className={'h-[20px] w-[20px]'} />
{option.label}
</Button>
))}
</div>
);
}
export default LoginProvider;

View File

@ -0,0 +1,67 @@
import { notify } from '@/components/_shared/notify';
import { AFConfigContext } from '@/components/app/AppConfig';
import { Button, CircularProgress, OutlinedInput } from '@mui/material';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import validator from 'validator';
function MagicLink({ redirectTo }: { redirectTo: string }) {
const { t } = useTranslation();
const [email, setEmail] = React.useState<string>('');
const [loading, setLoading] = React.useState<boolean>(false);
const service = useContext(AFConfigContext)?.service;
const handleSubmit = async () => {
const isValidEmail = validator.isEmail(email);
if (!isValidEmail) {
notify.error(t('signIn.invalidEmail'));
return;
}
setLoading(true);
try {
await service?.signInMagicLink({
email,
redirectTo,
});
notify.success(t('signIn.magicLinkSent'));
} catch (e) {
notify.error(t('web.signInError'));
} finally {
setLoading(false);
}
};
return (
<div className={'flex flex-col items-center justify-center gap-[12px]'}>
<OutlinedInput
value={email}
type={'email'}
className={'h-[46px] w-[380px] rounded-[12px] py-[15px] px-[20px] text-base'}
placeholder={t('signIn.pleaseInputYourEmail')}
inputProps={{
className: 'px-0 py-0',
}}
onChange={(e) => setEmail(e.target.value)}
/>
<Button
onClick={handleSubmit}
disabled={loading}
variant={'contained'}
className={'flex h-[46px] w-[380px] items-center justify-center gap-2 rounded-[12px] text-base'}
>
{loading ? (
<>
<CircularProgress size={'small'} />
{t('editor.loading')}...
</>
) : (
t('web.continue')
)}
</Button>
</div>
);
}
export default MagicLink;

View File

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

View File

@ -4,10 +4,10 @@ import { usePublishContext } from '@/application/publish';
import ComponentLoading from '@/components/_shared/progress/ComponentLoading';
import { useAppThemeMode } from '@/components/app/useAppThemeMode';
import { Database } from '@/components/database';
import { useViewMeta } from '@/components/publish/useViewMeta';
import { ViewMetaProps } from 'src/components/view-meta';
import React, { useMemo } from 'react';
import { Document } from '@/components/document';
import { useViewMeta } from '@/components/publish/useViewMeta';
import React, { useMemo } from 'react';
import { ViewMetaProps } from 'src/components/view-meta';
import Y from 'yjs';
export interface CollabViewProps {
@ -49,7 +49,7 @@ function CollabView({ doc }: CollabViewProps) {
}
return (
<div style={style} className={`relative w-full ${layoutClassName}`}>
<div style={style} className={`relative w-full flex-1 ${layoutClassName}`}>
<View
doc={doc}
loadViewMeta={loadViewMeta}
@ -61,6 +61,7 @@ function CollabView({ doc }: CollabViewProps) {
viewId={viewId}
name={name}
isDark={isDark}
layout={layout || ViewLayout.Document}
/>
</div>
);

View File

@ -1,4 +1,6 @@
// import { invalidToken } from '@/application/session/token';
import { Popover } from '@/components/_shared/popover';
// import { AFConfigContext } from '@/components/app/AppConfig';
import { ThemeModeContext } from '@/components/app/useAppThemeMode';
import { openUrl } from '@/utils/url';
import { IconButton } from '@mui/material';
@ -6,11 +8,14 @@ import React, { useContext, useMemo } from 'react';
import { ReactComponent as MoreIcon } from '@/assets/more.svg';
import { ReactComponent as MoonIcon } from '@/assets/moon.svg';
import { ReactComponent as SunIcon } from '@/assets/sun.svg';
// import { ReactComponent as LoginIcon } from '@/assets/login.svg';
import { ReactComponent as ReportIcon } from '@/assets/report.svg';
import { useTranslation } from 'react-i18next';
import { ReactComponent as Logo } from '@/assets/logo.svg';
import { ReactComponent as AppflowyLogo } from '@/assets/appflowy.svg';
// import { useNavigate } from 'react-router-dom';
function MoreActions() {
const { isDark, setDark } = useContext(ThemeModeContext) || {};
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
@ -26,8 +31,21 @@ function MoreActions() {
const { t } = useTranslation();
// const navigate = useNavigate();
// const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
//
// const handleLogin = useCallback(() => {
// invalidToken();
// navigate('/login?redirectTo=' + encodeURIComponent(window.location.href));
// }, [navigate]);
const actions = useMemo(() => {
return [
// {
// Icon: LoginIcon,
// label: isAuthenticated ? t('button.logout') : t('web.login'),
// onClick: handleLogin,
// },
isDark
? {
Icon: SunIcon,
@ -51,7 +69,7 @@ function MoreActions() {
},
},
];
}, [isDark, t, setDark]);
}, [t, isDark, setDark]);
return (
<>

View File

@ -7,7 +7,7 @@ import BuiltInImage6 from '@/assets/cover/m_cover_image_6.png';
import ViewCover, { CoverType } from '@/components/view-meta/ViewCover';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ViewMetaIcon } from '@/application/collab.type';
import { ViewLayout, ViewMetaIcon } from '@/application/collab.type';
export interface ViewMetaCover {
type: CoverType;
@ -19,6 +19,7 @@ export interface ViewMetaProps {
cover?: ViewMetaCover;
name?: string;
viewId?: string;
layout?: ViewLayout;
}
export function ViewMetaPreview({ icon, cover, name }: ViewMetaProps) {

View File

@ -0,0 +1,12 @@
import { Login } from '@/components/login';
import React from 'react';
function LoginPage() {
return (
<div className={'bg-body flex h-screen w-screen items-center justify-center'}>
<Login />
</div>
);
}
export default LoginPage;

View File

@ -34,7 +34,7 @@
"signIn": {
"loginTitle": "Login to @:appName",
"loginButtonText": "Login",
"loginStartWithAnonymous": "Start with an anonymous session",
"loginStartWithAnonymous": "Continue with an anonymous session",
"continueAnonymousUser": "Continue with an anonymous session",
"buttonText": "Sign In",
"signingInText": "Signing in...",
@ -47,24 +47,25 @@
"unmatchedPasswordError": "Repeat password is not the same as password",
"syncPromptMessage": "Syncing the data might take a while. Please don't close this page",
"or": "OR",
"signInWithGoogle": "Log in with Google",
"signInWithGithub": "Log in with Github",
"signInWithDiscord": "Log in with Discord",
"signInWithGoogle": "Continue with Google",
"signInWithGithub": "Continue with Github",
"signInWithDiscord": "Continue with Discord",
"signUpWithGoogle": "Sign up with Google",
"signUpWithGithub": "Sign up with Github",
"signUpWithDiscord": "Sign up with Discord",
"signInWith": "Sign in with:",
"signInWithEmail": "Sign in with Email",
"signInWithMagicLink": "Log in with Magic Link",
"signInWith": "Continue with:",
"signInWithEmail": "Continue with Email",
"signInWithMagicLink": "Continue",
"signUpWithMagicLink": "Sign up with Magic Link",
"pleaseInputYourEmail": "Please enter your email address",
"settings": "Settings",
"magicLinkSent": "We emailed a magic link. Click the link to log in.",
"magicLinkSent": "Magic Link sent!",
"invalidEmail": "Please enter a valid email address",
"alreadyHaveAnAccount": "Already have an account?",
"logIn": "Log in",
"generalError": "Something went wrong. Please try again later",
"limitRateError": "For security reasons, you can only request a magic link every 60 seconds"
"limitRateError": "For security reasons, you can only request a magic link every 60 seconds",
"magicLinkSentDescription": "A Magic Link was sent to your email. Click the link to complete your login. The link will expire after 5 minutes."
},
"workspace": {
"chooseWorkspace": "Choose your workspace",
@ -335,9 +336,9 @@
"logout": "Log out",
"deleteAccount": "Delete account",
"back": "Back",
"signInGoogle": "Sign in with Google",
"signInGithub": "Sign in with Github",
"signInDiscord": "Sign in with Discord",
"signInGoogle": "Continue with Google",
"signInGithub": "Continue with Github",
"signInDiscord": "Continue with Discord",
"more": "More",
"create": "Create",
"close": "Close"
@ -2041,7 +2042,6 @@
"upgrade": "Update",
"upgradeYourSpace": "Create multiple Spaces",
"quicklySwitch": "Quickly switch to the next space",
"duplicate": "Duplicate Space",
"movePageToSpace": "Move page to space",
"switchSpace": "Switch space"
@ -2066,5 +2066,18 @@
"createWithAppFlowy": "Create a website with AppFlowy",
"fastWithAI": "Fast and easy with AI.",
"tryItNow": "Try it now"
},
"web": {
"continue": "Continue",
"or": "or",
"continueWithGoogle": "Continue with Google",
"continueWithGithub": "Continue with GitHub",
"continueWithDiscord": "Continue with Discord",
"signInAgreement": "By clicking \"Continue\" above, you confirm that\nyou have read, understood, and agreed to\nAppFlowy's",
"and": "and",
"termOfUse": "Terms",
"privacyPolicy": "Privacy Policy",
"signInError": "Sign in error",
"login": "Sign up or log in"
}
}